Add support for SameSite cookie directive

Closes gh-1005
This commit is contained in:
Vedran Pavic
2018-07-26 23:50:10 +02:00
parent ad35d7ca30
commit 00465a6f00
11 changed files with 655 additions and 70 deletions

View File

@@ -16,8 +16,13 @@
package org.springframework.session.web.http;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.BitSet;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -41,6 +46,22 @@ public class DefaultCookieSerializer implements CookieSerializer {
private static final Log logger = LogFactory.getLog(DefaultCookieSerializer.class);
private static final BitSet domainValid = new BitSet(128);
static {
for (char c = '0'; c <= '9'; c++) {
domainValid.set(c);
}
for (char c = 'a'; c <= 'z'; c++) {
domainValid.set(c);
}
for (char c = 'A'; c <= 'Z'; c++) {
domainValid.set(c);
}
domainValid.set('.');
domainValid.set('-');
}
private String cookieName = "SESSION";
private Boolean useSecureCookie;
@@ -61,6 +82,8 @@ public class DefaultCookieSerializer implements CookieSerializer {
private String rememberMeRequestAttribute;
private String sameSite = "Lax";
/*
* (non-Javadoc)
*
@@ -75,7 +98,8 @@ public class DefaultCookieSerializer implements CookieSerializer {
for (Cookie cookie : cookies) {
if (this.cookieName.equals(cookie.getName())) {
String sessionId = (this.useBase64Encoding
? base64Decode(cookie.getValue()) : cookie.getValue());
? base64Decode(cookie.getValue())
: cookie.getValue());
if (sessionId == null) {
continue;
}
@@ -101,37 +125,43 @@ public class DefaultCookieSerializer implements CookieSerializer {
HttpServletRequest request = cookieValue.getRequest();
HttpServletResponse response = cookieValue.getResponse();
String requestedCookieValue = cookieValue.getCookieValue();
String actualCookieValue = (this.jvmRoute != null
? requestedCookieValue + this.jvmRoute : requestedCookieValue);
Cookie sessionCookie = new Cookie(this.cookieName, this.useBase64Encoding
? base64Encode(actualCookieValue) : actualCookieValue);
sessionCookie.setSecure(isSecureCookie(request));
sessionCookie.setPath(getCookiePath(request));
String domainName = getDomainName(request);
if (domainName != null) {
sessionCookie.setDomain(domainName);
StringBuilder sb = new StringBuilder();
sb.append(this.cookieName).append('=');
String value = getValue(cookieValue);
if (value != null && value.length() > 0) {
validateValue(value);
sb.append(value);
}
int maxAge = getMaxAge(cookieValue);
if (maxAge > -1) {
sb.append("; Max-Age=").append(cookieValue.getCookieMaxAge());
OffsetDateTime expires = (maxAge != 0
? OffsetDateTime.now().plusSeconds(maxAge)
: Instant.EPOCH.atOffset(ZoneOffset.UTC));
sb.append("; Expires=")
.append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME));
}
String domain = getDomainName(request);
if (domain != null && domain.length() > 0) {
validateDomain(domain);
sb.append("; Domain=").append(domain);
}
String path = getCookiePath(request);
if (path != null && path.length() > 0) {
validatePath(path);
sb.append("; Path=").append(path);
}
if (isSecureCookie(request)) {
sb.append("; Secure");
}
if (this.useHttpOnlyCookie) {
sessionCookie.setHttpOnly(true);
sb.append("; HttpOnly");
}
if (this.sameSite != null) {
sb.append("; SameSite=").append(this.sameSite);
}
if (cookieValue.getCookieMaxAge() < 0) {
if (this.rememberMeRequestAttribute != null
&& request.getAttribute(this.rememberMeRequestAttribute) != null) {
// the cookie is only written at time of session creation, so we rely on
// session expiration rather than cookie expiration if remember me is enabled
cookieValue.setCookieMaxAge(Integer.MAX_VALUE);
}
else if (this.cookieMaxAge != null) {
cookieValue.setCookieMaxAge(this.cookieMaxAge);
}
}
sessionCookie.setMaxAge(cookieValue.getCookieMaxAge());
response.addCookie(sessionCookie);
response.addHeader("Set-Cookie", sb.toString());
}
/**
@@ -162,6 +192,81 @@ public class DefaultCookieSerializer implements CookieSerializer {
return new String(encodedCookieBytes);
}
private String getValue(CookieValue cookieValue) {
String requestedCookieValue = cookieValue.getCookieValue();
String actualCookieValue = requestedCookieValue;
if (this.jvmRoute != null) {
actualCookieValue = requestedCookieValue + this.jvmRoute;
}
if (this.useBase64Encoding) {
actualCookieValue = base64Encode(actualCookieValue);
}
return actualCookieValue;
}
private void validateValue(String value) {
int start = 0;
int end = value.length();
if ((end > 1) && (value.charAt(0) == '"') && (value.charAt(end - 1) == '"')) {
start = 1;
end--;
}
char[] chars = value.toCharArray();
for (int i = start; i < end; i++) {
char c = chars[i];
if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c
|| c == 0x7f) {
throw new IllegalArgumentException(
"Invalid character in cookie value: " + Integer.toString(c));
}
}
}
private int getMaxAge(CookieValue cookieValue) {
int maxAge = cookieValue.getCookieMaxAge();
if (maxAge < 0) {
if (this.rememberMeRequestAttribute != null && cookieValue.getRequest()
.getAttribute(this.rememberMeRequestAttribute) != null) {
// the cookie is only written at time of session creation, so we rely on
// session expiration rather than cookie expiration if remember me is
// enabled
cookieValue.setCookieMaxAge(Integer.MAX_VALUE);
}
else if (this.cookieMaxAge != null) {
cookieValue.setCookieMaxAge(this.cookieMaxAge);
}
}
return cookieValue.getCookieMaxAge();
}
private void validateDomain(String domain) {
int i = 0;
int cur = -1;
int prev;
char[] chars = domain.toCharArray();
while (i < chars.length) {
prev = cur;
cur = chars[i];
if (!domainValid.get(cur)
|| ((prev == '.' || prev == -1) && (cur == '.' || cur == '-'))
|| (prev == '-' && cur == '.')) {
throw new IllegalArgumentException("Invalid cookie domain: " + domain);
}
i++;
}
if (cur == '.' || cur == '-') {
throw new IllegalArgumentException("Invalid cookie domain: " + domain);
}
}
private void validatePath(String path) {
for (char ch : path.toCharArray()) {
if (ch < 0x20 || ch > 0x7E || ch == ';') {
throw new IllegalArgumentException("Invalid cookie path: " + path);
}
}
}
/**
* Sets if a Cookie marked as secure should be used. The default is to use the value
* of {@link HttpServletRequest#isSecure()}.
@@ -317,6 +422,16 @@ public class DefaultCookieSerializer implements CookieSerializer {
this.rememberMeRequestAttribute = rememberMeRequestAttribute;
}
/**
* Set the value for the {@code SameSite} cookie directive. The default value is
* {@code Lax}.
* @param sameSite the SameSite directive value
* @since 2.1.0
*/
public void setSameSite(String sameSite) {
this.sameSite = sameSite;
}
private String getDomainName(HttpServletRequest request) {
if (this.domainName != null) {
return this.domainName;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014-2017 the original author or authors.
* Copyright 2014-2018 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,12 +18,14 @@ package org.springframework.session.web.http;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import javax.servlet.http.Cookie;
import org.junit.Before;
import org.junit.Test;
import org.springframework.http.ResponseCookie;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.session.MapSession;
@@ -83,7 +85,7 @@ public class CookieHttpSessionIdResolverTests {
this.strategy.setSessionId(this.request, this.response, this.session.getId());
this.strategy.setSessionId(this.request, this.response, this.session.getId());
assertThat(this.response.getCookies()).hasSize(1);
assertThat(this.response.getHeaders("Set-Cookie")).hasSize(1);
}
@Test
@@ -93,11 +95,12 @@ public class CookieHttpSessionIdResolverTests {
this.strategy.setSessionId(this.request, this.response, this.session.getId());
this.strategy.setSessionId(this.request, this.response, newSession.getId());
Cookie[] cookies = this.response.getCookies();
List<ResponseCookie> cookies = ResponseCookieParser.parse(this.response);
assertThat(cookies).hasSize(2);
assertThat(base64Decode(cookies[0].getValue())).isEqualTo(this.session.getId());
assertThat(base64Decode(cookies[1].getValue())).isEqualTo(newSession.getId());
assertThat(base64Decode(cookies.get(0).getValue()))
.isEqualTo(this.session.getId());
assertThat(base64Decode(cookies.get(1).getValue())).isEqualTo(newSession.getId());
}
@Test
@@ -105,7 +108,7 @@ public class CookieHttpSessionIdResolverTests {
this.request.setContextPath("/somethingunique");
this.strategy.setSessionId(this.request, this.response, this.session.getId());
Cookie sessionCookie = this.response.getCookie(this.cookieName);
ResponseCookie sessionCookie = getCookie();
assertThat(sessionCookie.getPath())
.isEqualTo(this.request.getContextPath() + "/");
}
@@ -128,7 +131,7 @@ public class CookieHttpSessionIdResolverTests {
this.request.setContextPath("/somethingunique");
this.strategy.expireSession(this.request, this.response);
Cookie sessionCookie = this.response.getCookie(this.cookieName);
ResponseCookie sessionCookie = getCookie();
assertThat(sessionCookie.getPath())
.isEqualTo(this.request.getContextPath() + "/");
}
@@ -173,8 +176,12 @@ public class CookieHttpSessionIdResolverTests {
this.request.setCookies(new Cookie(this.cookieName, base64Encode(value)));
}
private ResponseCookie getCookie() {
return ResponseCookieParser.parse(this.response, this.cookieName);
}
private String getSessionId() {
return base64Decode(this.response.getCookie(this.cookieName).getValue());
return base64Decode(getCookie().getValue());
}
private static String base64Encode(String value) {

View File

@@ -26,6 +26,7 @@ import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.springframework.http.ResponseCookie;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.session.web.http.CookieSerializer.CookieValue;
@@ -324,7 +325,7 @@ public class DefaultCookieSerializerTests {
public void writeCookieCookieMaxAgeDefault() {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getMaxAge()).isEqualTo(-1);
assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(-1);
}
@Test
@@ -333,7 +334,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getMaxAge()).isEqualTo(100);
assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(100);
}
@Test
@@ -342,7 +343,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue(""));
assertThat(getCookie().getMaxAge()).isEqualTo(0);
assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(0);
}
@Test
@@ -352,7 +353,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue);
assertThat(getCookie().getMaxAge()).isEqualTo(100);
assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(100);
}
// --- secure ---
@@ -361,7 +362,7 @@ public class DefaultCookieSerializerTests {
public void writeCookieDefaultInsecureRequest() {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSecure()).isFalse();
assertThat(getCookie().isSecure()).isFalse();
}
@Test
@@ -371,7 +372,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSecure()).isTrue();
assertThat(getCookie().isSecure()).isTrue();
}
@Test
@@ -380,7 +381,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSecure()).isTrue();
assertThat(getCookie().isSecure()).isTrue();
}
@Test
@@ -390,7 +391,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSecure()).isFalse();
assertThat(getCookie().isSecure()).isFalse();
}
@Test
@@ -399,7 +400,7 @@ public class DefaultCookieSerializerTests {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSecure()).isFalse();
assertThat(getCookie().isSecure()).isFalse();
}
// --- jvmRoute ---
@@ -452,7 +453,7 @@ public class DefaultCookieSerializerTests {
this.serializer.setRememberMeRequestAttribute("rememberMe");
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getMaxAge()).isEqualTo(Integer.MAX_VALUE);
assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(Integer.MAX_VALUE);
}
@Test
@@ -463,7 +464,40 @@ public class DefaultCookieSerializerTests {
cookieValue.setCookieMaxAge(100);
this.serializer.writeCookieValue(cookieValue);
assertThat(getCookie().getMaxAge()).isEqualTo(100);
assertThat(getCookie().getMaxAge().getSeconds()).isEqualTo(100);
}
// --- sameSite ---
@Test
public void writeCookieDefaultSameSiteLax() {
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSameSite()).isEqualTo("Lax");
}
@Test
public void writeCookieSetSameSiteLax() {
this.serializer.setSameSite("Lax");
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSameSite()).isEqualTo("Lax");
}
@Test
public void writeCookieSetSameSiteStrict() {
this.serializer.setSameSite("Strict");
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSameSite()).isEqualTo("Strict");
}
@Test
public void writeCookieSetSameSiteNull() {
this.serializer.setSameSite(null);
this.serializer.writeCookieValue(cookieValue(this.sessionId));
assertThat(getCookie().getSameSite()).isNull();
}
public void setCookieName(String cookieName) {
@@ -478,8 +512,8 @@ public class DefaultCookieSerializerTests {
return new Cookie(name, value);
}
private Cookie getCookie() {
return this.response.getCookie(this.cookieName);
private ResponseCookie getCookie() {
return ResponseCookieParser.parse(this.response, this.cookieName);
}
private String getCookieValue() {
@@ -487,9 +521,6 @@ public class DefaultCookieSerializerTests {
if (!this.useBase64Encoding) {
return value;
}
if (value == null) {
return null;
}
return new String(Base64.getDecoder().decode(value));
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2014-2018 the original author or authors.
*
* Licensed 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.springframework.session.web.http;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.lang.NonNull;
final class ResponseCookieParser {
private ResponseCookieParser() {
}
static List<ResponseCookie> parse(HttpServletResponse response) {
return doParse(response, null);
}
static ResponseCookie parse(HttpServletResponse response, String cookieName) {
List<ResponseCookie> responseCookies = doParse(response, cookieName);
return (!responseCookies.isEmpty() ? responseCookies.get(0) : null);
}
@NonNull
private static List<ResponseCookie> doParse(HttpServletResponse response,
String cookieName) {
List<ResponseCookie> responseCookies = new ArrayList<>();
for (String setCookieHeader : response.getHeaders(HttpHeaders.SET_COOKIE)) {
String[] cookieParts = setCookieHeader.split("\\s*=\\s*", 2);
if (cookieParts.length != 2) {
return null;
}
String name = cookieParts[0];
if (cookieName != null && !name.equals(cookieName)) {
continue;
}
String[] valueAndDirectives = cookieParts[1].split("\\s*;\\s*", 2);
String value = valueAndDirectives[0];
String[] directives = valueAndDirectives[1].split("\\s*;\\s*");
String domain = null;
int maxAge = -1;
String path = null;
boolean secure = false;
boolean httpOnly = false;
String sameSite = null;
for (String directive : directives) {
if (directive.startsWith("Domain")) {
domain = directive.split("=")[1];
}
if (directive.startsWith("Max-Age")) {
maxAge = Integer.parseInt(directive.split("=")[1]);
}
if (directive.startsWith("Path")) {
path = directive.split("=")[1];
}
if (directive.startsWith("Secure")) {
secure = true;
}
if (directive.startsWith("HttpOnly")) {
httpOnly = true;
}
if (directive.startsWith("SameSite")) {
sameSite = directive.split("=")[1];
}
}
responseCookies.add(ResponseCookie.from(name, value).maxAge(maxAge).path(path)
.domain(domain).secure(secure).httpOnly(httpOnly).sameSite(sameSite)
.build());
}
return responseCookies;
}
}

View File

@@ -51,6 +51,7 @@ import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.http.ResponseCookie;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -423,7 +424,7 @@ public class SessionRepositoryFilterTests {
}
});
assertThat(this.response.getCookie("SESSION")).isNull();
assertThat(getSessionCookie()).isNull();
}
@Test
@@ -441,7 +442,7 @@ public class SessionRepositoryFilterTests {
wrappedRequest.getSession();
}
});
assertThat(this.response.getCookie("SESSION")).isNotNull();
assertThat(getSessionCookie()).isNotNull();
nextRequest();
@@ -453,7 +454,7 @@ public class SessionRepositoryFilterTests {
}
});
assertThat(this.response.getCookie("SESSION")).isNotNull();
assertThat(getSessionCookie()).isNotNull();
}
@Test
@@ -653,10 +654,10 @@ public class SessionRepositoryFilterTests {
}
});
Cookie session = getSessionCookie();
ResponseCookie session = getSessionCookie();
assertThat(session.isHttpOnly()).describedAs("Session Cookie should be HttpOnly")
.isTrue();
assertThat(session.getSecure())
assertThat(session.isSecure())
.describedAs("Session Cookie should be marked as Secure").isTrue();
}
@@ -1509,13 +1510,13 @@ public class SessionRepositoryFilterTests {
// --- helper methods
private void assertNewSession() {
Cookie cookie = getSessionCookie();
ResponseCookie cookie = getSessionCookie();
assertThat(cookie).isNotNull();
assertThat(cookie.getMaxAge()).isEqualTo(-1);
assertThat(cookie.getMaxAge().getSeconds()).isEqualTo(-1);
assertThat(cookie.getValue()).isNotEqualTo("INVALID");
assertThat(cookie.isHttpOnly()).describedAs("Cookie is expected to be HTTP Only")
.isTrue();
assertThat(cookie.getSecure())
assertThat(cookie.isSecure())
.describedAs(
"Cookie secured is expected to be " + this.request.isSecure())
.isEqualTo(this.request.isSecure());
@@ -1525,15 +1526,15 @@ public class SessionRepositoryFilterTests {
}
private void assertNoSession() {
Cookie cookie = getSessionCookie();
ResponseCookie cookie = getSessionCookie();
assertThat(cookie).isNull();
assertThat(this.request.getSession(false))
.describedAs("The original HttpServletRequest HttpSession should be null")
.isNull();
}
private Cookie getSessionCookie() {
return this.response.getCookie("SESSION");
private ResponseCookie getSessionCookie() {
return ResponseCookieParser.parse(this.response, "SESSION");
}
private void setSessionCookie(String sessionId) {
@@ -1556,6 +1557,9 @@ public class SessionRepositoryFilterTests {
for (Cookie cookie : this.response.getCookies()) {
nameToCookie.put(cookie.getName(), cookie);
}
ResponseCookieParser.parse(this.response)
.forEach((responseCookie) -> nameToCookie.put(responseCookie.getName(),
toServletCookie(responseCookie)));
Cookie[] nextRequestCookies = new ArrayList<>(nameToCookie.values())
.toArray(new Cookie[0]);
@@ -1586,6 +1590,19 @@ public class SessionRepositoryFilterTests {
return new String(Base64.getDecoder().decode(value));
}
private static Cookie toServletCookie(ResponseCookie responseCookie) {
Cookie cookie = new Cookie(responseCookie.getName(), responseCookie.getValue());
String domain = responseCookie.getDomain();
if (domain != null) {
cookie.setDomain(domain);
}
cookie.setMaxAge((int) responseCookie.getMaxAge().getSeconds());
cookie.setPath(responseCookie.getPath());
cookie.setSecure(responseCookie.isSecure());
cookie.setHttpOnly(responseCookie.isHttpOnly());
return cookie;
}
private static class SessionRepositoryFilterDefaultOrder implements Ordered {
@Override