NIFI-10899 Added SameSite Policy to Application Cookies

- Added __Secure prefix to Application Cookie Names

Signed-off-by: Nathan Gough <thenatog@gmail.com>

This closes #6735.
This commit is contained in:
exceptionfactory 2022-11-29 14:04:10 -06:00 committed by Nathan Gough
parent 3dc48f0894
commit 45a31c7286
6 changed files with 160 additions and 104 deletions

View File

@ -22,21 +22,35 @@ import org.apache.nifi.web.security.http.SecurityCookieName;
* Application Cookie Names
*/
public enum ApplicationCookieName {
AUTHORIZATION_BEARER(SecurityCookieName.AUTHORIZATION_BEARER.getName()),
/** Authorization Bearer contains signed JSON Web Token and requires Strict Same Site handling */
AUTHORIZATION_BEARER(SecurityCookieName.AUTHORIZATION_BEARER.getName(), SameSitePolicy.STRICT),
LOGOUT_REQUEST_IDENTIFIER("nifi-logout-request-identifier"),
/** Cross-Site Request Forgery mitigation token requires Strict Same Site handling */
REQUEST_TOKEN(SecurityCookieName.REQUEST_TOKEN.getName(), SameSitePolicy.STRICT),
OIDC_REQUEST_IDENTIFIER("nifi-oidc-request-identifier"),
/** Logout Requests can interact with external identity providers requiring no Same Site restrictions */
LOGOUT_REQUEST_IDENTIFIER("__Secure-Logout-Request-Identifier", SameSitePolicy.NONE),
SAML_REQUEST_IDENTIFIER("nifi-saml-request-identifier");
/** OpenID Connect Requests use external identity providers requiring no Same Site restrictions */
OIDC_REQUEST_IDENTIFIER("__Secure-OIDC-Request-Identifier", SameSitePolicy.NONE),
/** SAML Requests use external identity providers requiring no Same Site restrictions */
SAML_REQUEST_IDENTIFIER("__Secure-SAML-Request-Identifier", SameSitePolicy.NONE);
private final String cookieName;
ApplicationCookieName(final String cookieName) {
private final SameSitePolicy sameSitePolicy;
ApplicationCookieName(final String cookieName, final SameSitePolicy sameSitePolicy) {
this.cookieName = cookieName;
this.sameSitePolicy = sameSitePolicy;
}
public String getCookieName() {
return cookieName;
}
public SameSitePolicy getSameSitePolicy() {
return sameSitePolicy;
}
}

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.web.security.cookie;
/**
* Cookie SameSite attribute policy
*/
public enum SameSitePolicy {
/** Instructs browsers to limit sending cookies to first-party-initiated requests */
STRICT("Strict"),
/** Instructs browsers to send cookies for both first-party and cross-site requests */
NONE("None");
private final String policy;
SameSitePolicy(final String policy) {
this.policy = policy;
}
public String getPolicy() {
return policy;
}
}

View File

@ -43,8 +43,6 @@ public class StandardApplicationCookieService implements ApplicationCookieServic
private static final String DEFAULT_PATH = "/";
private static final String SAME_SITE_STRICT = "Strict";
private static final boolean SECURE_ENABLED = true;
private static final boolean HTTP_ONLY_ENABLED = true;
@ -77,7 +75,6 @@ public class StandardApplicationCookieService implements ApplicationCookieServic
@Override
public void addSessionCookie(final URI resourceUri, final HttpServletResponse response, final ApplicationCookieName applicationCookieName, final String value) {
final ResponseCookie.ResponseCookieBuilder responseCookieBuilder = getCookieBuilder(resourceUri, applicationCookieName, value, MAX_AGE_SESSION);
responseCookieBuilder.sameSite(SAME_SITE_STRICT);
setResponseCookie(response, responseCookieBuilder.build());
logger.debug("Added Session Cookie [{}] URI [{}]", applicationCookieName.getCookieName(), resourceUri);
}
@ -110,15 +107,27 @@ public class StandardApplicationCookieService implements ApplicationCookieServic
logger.debug("Removed Cookie [{}] URI [{}]", applicationCookieName.getCookieName(), resourceUri);
}
private ResponseCookie.ResponseCookieBuilder getCookieBuilder(final URI resourceUri,
/**
* Get Response Cookie Builder with standard properties
*
* @param resourceUri Resource URI containing path and domain
* @param applicationCookieName Application Cookie Name to be used
* @param value Cookie value
* @param maxAge Max Age
* @return Response Cookie Builder
*/
protected ResponseCookie.ResponseCookieBuilder getCookieBuilder(final URI resourceUri,
final ApplicationCookieName applicationCookieName,
final String value,
final Duration maxAge) {
Objects.requireNonNull(resourceUri, "Resource URI required");
Objects.requireNonNull(applicationCookieName, "Response Cookie Name required");
final SameSitePolicy sameSitePolicy = applicationCookieName.getSameSitePolicy();
return ResponseCookie.from(applicationCookieName.getCookieName(), value)
.path(getCookiePath(resourceUri))
.domain(resourceUri.getHost())
.sameSite(sameSitePolicy.getPolicy())
.secure(SECURE_ENABLED)
.httpOnly(HTTP_ONLY_ENABLED)
.maxAge(maxAge);

View File

@ -16,9 +16,13 @@
*/
package org.apache.nifi.web.security.csrf;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.cookie.ApplicationCookieService;
import org.apache.nifi.web.security.cookie.StandardApplicationCookieService;
import org.apache.nifi.web.security.http.SecurityCookieName;
import org.apache.nifi.web.security.http.SecurityHeader;
import org.apache.nifi.web.util.RequestUriBuilder;
import org.springframework.http.ResponseCookie;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.DefaultCsrfToken;
@ -29,6 +33,7 @@ import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URI;
import java.time.Duration;
import java.util.UUID;
/**
@ -37,15 +42,7 @@ import java.util.UUID;
public class StandardCookieCsrfTokenRepository implements CsrfTokenRepository {
private static final String REQUEST_PARAMETER = "requestToken";
private static final String ROOT_PATH = "/";
private static final String EMPTY = "";
private static final boolean SECURE_ENABLED = true;
private static final int MAX_AGE_EXPIRED = 0;
private static final int MAX_AGE_SESSION = -1;
private static final ApplicationCookieService applicationCookieService = new CsrfApplicationCookieService();
/**
* Generate CSRF Token or return current Token when present in HTTP Servlet Request Cookie header
@ -71,16 +68,13 @@ public class StandardCookieCsrfTokenRepository implements CsrfTokenRepository {
*/
@Override
public void saveToken(final CsrfToken csrfToken, final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse) {
final String token = csrfToken == null ? EMPTY : csrfToken.getToken();
final int maxAge = csrfToken == null ? MAX_AGE_EXPIRED : MAX_AGE_SESSION;
final Cookie cookie = new Cookie(SecurityCookieName.REQUEST_TOKEN.getName(), token);
cookie.setSecure(SECURE_ENABLED);
cookie.setMaxAge(maxAge);
final String cookiePath = getCookiePath(httpServletRequest);
cookie.setPath(cookiePath);
httpServletResponse.addCookie(cookie);
final URI uri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).build();
if (csrfToken == null) {
applicationCookieService.removeCookie(uri, httpServletResponse, ApplicationCookieName.REQUEST_TOKEN);
} else {
final String token = csrfToken.getToken();
applicationCookieService.addSessionCookie(uri, httpServletResponse, ApplicationCookieName.REQUEST_TOKEN, token);
}
}
/**
@ -104,10 +98,26 @@ public class StandardCookieCsrfTokenRepository implements CsrfTokenRepository {
return UUID.randomUUID().toString();
}
private String getCookiePath(final HttpServletRequest httpServletRequest) {
final RequestUriBuilder requestUriBuilder = RequestUriBuilder.fromHttpServletRequest(httpServletRequest);
requestUriBuilder.path(ROOT_PATH);
final URI uri = requestUriBuilder.build();
return uri.getPath();
private static class CsrfApplicationCookieService extends StandardApplicationCookieService {
private static final boolean HTTP_ONLY_DISABLED = false;
/**
* Get Response Cookie Builder with HttpOnly disabled allowing JavaScript to read value for subsequent requests
*
* @param resourceUri Resource URI containing path and domain
* @param applicationCookieName Application Cookie Name to be used
* @param value Cookie value
* @param maxAge Max Age
* @return Response Cookie Builder
*/
@Override
protected ResponseCookie.ResponseCookieBuilder getCookieBuilder(final URI resourceUri,
final ApplicationCookieName applicationCookieName,
final String value,
final Duration maxAge) {
final ResponseCookie.ResponseCookieBuilder builder = super.getCookieBuilder(resourceUri, applicationCookieName, value, maxAge);
builder.httpOnly(HTTP_ONLY_DISABLED);
return builder;
}
}
}

View File

@ -17,13 +17,13 @@
package org.apache.nifi.web.security.cookie;
import org.apache.nifi.util.StringUtils;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockCookie;
import javax.servlet.http.Cookie;
@ -34,14 +34,14 @@ import java.net.URI;
import java.util.Optional;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
@ExtendWith(MockitoExtension.class)
public class StandardApplicationCookieServiceTest {
private static final String DOMAIN = "localhost.localdomain";
@ -59,7 +59,7 @@ public class StandardApplicationCookieServiceTest {
private static final int REMOVE_MAX_AGE = 0;
private static final String SAME_SITE_STRICT = "SameSite=Strict";
private static final String SAME_SITE = "SameSite";
private static final String COOKIE_VALUE = UUID.randomUUID().toString();
@ -80,7 +80,7 @@ public class StandardApplicationCookieServiceTest {
@Captor
private ArgumentCaptor<String> cookieArgumentCaptor;
@Before
@BeforeEach
public void setService() {
service = new StandardApplicationCookieService();
resourceUri = URI.create(RESOURCE_URI);
@ -113,7 +113,6 @@ public class StandardApplicationCookieServiceTest {
final String setCookieHeader = cookieArgumentCaptor.getValue();
assertAddCookieMatches(setCookieHeader, ROOT_PATH, SESSION_MAX_AGE);
assertTrue("SameSite not found", setCookieHeader.endsWith(SAME_SITE_STRICT));
}
@Test
@ -124,7 +123,6 @@ public class StandardApplicationCookieServiceTest {
final String setCookieHeader = cookieArgumentCaptor.getValue();
assertAddCookieMatches(setCookieHeader, CONTEXT_PATH, SESSION_MAX_AGE);
assertTrue("SameSite not found", setCookieHeader.endsWith(SAME_SITE_STRICT));
}
@Test
@ -175,10 +173,11 @@ public class StandardApplicationCookieServiceTest {
}
private void assertCookieMatches(final String setCookieHeader, final Cookie cookie, final String path) {
assertEquals("Cookie Name not matched", COOKIE_NAME.getCookieName(), cookie.getName());
assertEquals("Path not matched", path, cookie.getPath());
assertEquals("Domain not matched", DOMAIN, cookie.getDomain());
assertTrue("HTTP Only not matched", cookie.isHttpOnly());
assertTrue("Secure not matched", cookie.getSecure());
assertEquals(COOKIE_NAME.getCookieName(), cookie.getName(), "Cookie Name not matched");
assertEquals(path, cookie.getPath(), "Path not matched");
assertEquals(DOMAIN, cookie.getDomain(), "Domain not matched");
assertTrue(cookie.isHttpOnly(), "HTTP Only not matched");
assertTrue(cookie.getSecure(), "Secure not matched");
assertTrue(setCookieHeader.contains(SAME_SITE), "SameSite not found");
}
}

View File

@ -20,30 +20,20 @@ import org.apache.nifi.web.security.http.SecurityCookieName;
import org.apache.nifi.web.util.WebUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.web.csrf.CsrfToken;
import javax.servlet.ServletContext;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class StandardCookieCsrfTokenRepositoryTest {
private static final String ALLOWED_CONTEXT_PATHS_PARAMETER = "allowedContextPaths";
@ -55,8 +45,6 @@ public class StandardCookieCsrfTokenRepositoryTest {
private static final String CONTEXT_PATH = "/context-path";
private static final String COOKIE_CONTEXT_PATH = CONTEXT_PATH + ROOT_PATH;
private static final String HTTPS = "https";
private static final String HOST = "localhost";
@ -65,23 +53,21 @@ public class StandardCookieCsrfTokenRepositoryTest {
private static final String EMPTY = "";
@Mock
private HttpServletRequest request;
private static final String SET_COOKIE_HEADER = "Set-Cookie";
@Mock
private HttpServletResponse response;
private static final String SAME_SITE = "SameSite";
@Mock
private ServletContext servletContext;
private MockHttpServletRequest request;
@Captor
private ArgumentCaptor<Cookie> cookieArgumentCaptor;
private MockHttpServletResponse response;
private StandardCookieCsrfTokenRepository repository;
@BeforeEach
public void setRepository() {
this.repository = new StandardCookieCsrfTokenRepository();
this.request = new MockHttpServletRequest();
this.response = new MockHttpServletResponse();
}
@Test
@ -95,7 +81,7 @@ public class StandardCookieCsrfTokenRepositoryTest {
public void testGenerateTokenCookieFound() {
final String token = UUID.randomUUID().toString();
final Cookie cookie = new Cookie(SecurityCookieName.REQUEST_TOKEN.getName(), token);
when(request.getCookies()).thenReturn(new Cookie[]{cookie});
request.setCookies(cookie);
final CsrfToken csrfToken = repository.generateToken(request);
assertNotNull(csrfToken);
@ -106,7 +92,7 @@ public class StandardCookieCsrfTokenRepositoryTest {
public void testLoadToken() {
final String token = UUID.randomUUID().toString();
final Cookie cookie = new Cookie(SecurityCookieName.REQUEST_TOKEN.getName(), token);
when(request.getCookies()).thenReturn(new Cookie[]{cookie});
request.setCookies(cookie);
final CsrfToken csrfToken = repository.loadToken(request);
assertNotNull(csrfToken);
@ -115,31 +101,22 @@ public class StandardCookieCsrfTokenRepositoryTest {
@Test
public void testSaveToken() {
when(request.getServletContext()).thenReturn(servletContext);
final CsrfToken csrfToken = repository.generateToken(request);
repository.saveToken(csrfToken, request, response);
verify(response).addCookie(cookieArgumentCaptor.capture());
final Cookie cookie = cookieArgumentCaptor.getValue();
assertCookieEquals(csrfToken, cookie);
final Cookie cookie = assertCookieFound();
final String setCookieHeader = response.getHeader(SET_COOKIE_HEADER);
assertCookieEquals(csrfToken.getToken(), MAX_AGE_SESSION, cookie, setCookieHeader);
assertEquals(ROOT_PATH, cookie.getPath());
}
@Test
public void testSaveTokenNullCsrfToken() {
when(request.getServletContext()).thenReturn(servletContext);
repository.saveToken(null, request, response);
verify(response).addCookie(cookieArgumentCaptor.capture());
final Cookie cookie = cookieArgumentCaptor.getValue();
assertEquals(ROOT_PATH, cookie.getPath());
assertEquals(EMPTY, cookie.getValue());
assertEquals(MAX_AGE_EXPIRED, cookie.getMaxAge());
assertTrue(cookie.getSecure());
assertFalse(cookie.isHttpOnly());
assertNull(cookie.getDomain());
final Cookie cookie = assertCookieFound();
final String setCookieHeader = response.getHeader(SET_COOKIE_HEADER);
assertCookieEquals(EMPTY, MAX_AGE_EXPIRED, cookie, setCookieHeader);
}
@Test
@ -147,27 +124,36 @@ public class StandardCookieCsrfTokenRepositoryTest {
this.repository = new StandardCookieCsrfTokenRepository();
final CsrfToken csrfToken = repository.generateToken(request);
when(request.getHeader(eq(WebUtils.PROXY_SCHEME_HTTP_HEADER))).thenReturn(HTTPS);
when(request.getHeader(eq(WebUtils.PROXY_HOST_HTTP_HEADER))).thenReturn(HOST);
when(request.getHeader(eq(WebUtils.PROXY_PORT_HTTP_HEADER))).thenReturn(PORT);
when(request.getHeader(eq(WebUtils.PROXY_CONTEXT_PATH_HTTP_HEADER))).thenReturn(CONTEXT_PATH);
when(servletContext.getInitParameter(eq(ALLOWED_CONTEXT_PATHS_PARAMETER))).thenReturn(CONTEXT_PATH);
when(request.getServletContext()).thenReturn(servletContext);
request.addHeader(WebUtils.PROXY_SCHEME_HTTP_HEADER, HTTPS);
request.addHeader(WebUtils.PROXY_HOST_HTTP_HEADER, HOST);
request.addHeader(WebUtils.PROXY_PORT_HTTP_HEADER, PORT);
request.addHeader(WebUtils.PROXY_CONTEXT_PATH_HTTP_HEADER, CONTEXT_PATH);
final MockServletContext servletContext = (MockServletContext) request.getServletContext();
servletContext.setInitParameter(ALLOWED_CONTEXT_PATHS_PARAMETER, CONTEXT_PATH);
repository.saveToken(csrfToken, request, response);
verify(response).addCookie(cookieArgumentCaptor.capture());
final Cookie cookie = cookieArgumentCaptor.getValue();
assertCookieEquals(csrfToken, cookie);
assertEquals(COOKIE_CONTEXT_PATH, cookie.getPath());
final Cookie cookie = assertCookieFound();
final String setCookieHeader = response.getHeader(SET_COOKIE_HEADER);
assertCookieEquals(csrfToken.getToken(), MAX_AGE_SESSION, cookie, setCookieHeader);
assertEquals(CONTEXT_PATH, cookie.getPath());
}
private void assertCookieEquals(final CsrfToken csrfToken, final Cookie cookie) {
assertEquals(csrfToken.getToken(), cookie.getValue());
assertEquals(MAX_AGE_SESSION, cookie.getMaxAge());
private Cookie assertCookieFound() {
final Cookie cookie = response.getCookie(SecurityCookieName.REQUEST_TOKEN.getName());
assertNotNull(cookie);
return cookie;
}
private void assertCookieEquals(final String token, final int maxAge, final Cookie cookie, final String setCookieHeader) {
assertNotNull(setCookieHeader);
assertEquals(token, cookie.getValue());
assertEquals(maxAge, cookie.getMaxAge());
assertTrue(cookie.getSecure());
assertFalse(cookie.isHttpOnly());
assertNull(cookie.getDomain());
assertEquals(HOST, cookie.getDomain());
assertTrue(setCookieHeader.contains(SAME_SITE), "SameSite not found");
}
}