Issue #4247 SameSite Session Cookie (#4271)

* Issue #4247 SameSite Session Cookie

Allows sameSite cookie settings to be configured in SessionCookieConfig comments

Signed-off-by: Greg Wilkins <gregw@webtide.com>

* Issue #4247 SameSite Session Cookies

Use non versioned cookie

Signed-off-by: Greg Wilkins <gregw@webtide.com>

* Issue #4247 SameSite Session Cookies

Added test and fixed getCommentWithAttributes

Signed-off-by: Greg Wilkins <gregw@webtide.com>

* Issue #4247 - Updating unit tests for HttpCookie

Signed-off-by: Joakim Erdfelt <joakim.erdfelt@gmail.com>

* Issue #4247 SameSite Session Cookie

While it may be best practise to always use Secure cookies when SameSite is None, there is nothing in the RFC that mandates it and thus I don't believe we should prevent such a configuration.  If browsers enforce this, then users will find out soon enough... and if browsers change, then we are not required to do a new release to match.

Signed-off-by: Greg Wilkins <gregw@webtide.com>

* Issue #4247 SameSite Session Cookie

For cookie comments with multiple SameSite attributes, the most strict
value is used. So `Strict` has precedence over `Lax` which has
precedence over `None`.

Signed-off-by: Greg Wilkins <gregw@webtide.com>
This commit is contained in:
Greg Wilkins 2019-11-14 11:56:40 +11:00 committed by GitHub
parent b24c6ce68d
commit 32931fac7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 284 additions and 96 deletions

View File

@ -24,7 +24,7 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jetty.util.QuotedStringTokenizer; import org.eclipse.jetty.util.QuotedStringTokenizer;
import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.StringUtil;
// TODO consider replacing this with java.net.HttpCookie // TODO consider replacing this with java.net.HttpCookie (once it supports RFC6265)
public class HttpCookie public class HttpCookie
{ {
private static final String __COOKIE_DELIM = "\",;\\ \t"; private static final String __COOKIE_DELIM = "\",;\\ \t";
@ -33,14 +33,14 @@ public class HttpCookie
/** /**
*If this string is found within the comment parsed with {@link #isHttpOnlyInComment(String)} the check will return true *If this string is found within the comment parsed with {@link #isHttpOnlyInComment(String)} the check will return true
**/ **/
private static final String HTTP_ONLY_COMMENT = "__HTTP_ONLY__"; public static final String HTTP_ONLY_COMMENT = "__HTTP_ONLY__";
/** /**
*These strings are used by {@link #getSameSiteFromComment(String)} to check for a SameSite specifier in the comment *These strings are used by {@link #getSameSiteFromComment(String)} to check for a SameSite specifier in the comment
**/ **/
private static final String SAME_SITE_COMMENT = "__SAME_SITE_"; private static final String SAME_SITE_COMMENT = "__SAME_SITE_";
private static final String SAME_SITE_NONE_COMMENT = SAME_SITE_COMMENT + "NONE__"; public static final String SAME_SITE_NONE_COMMENT = SAME_SITE_COMMENT + "NONE__";
private static final String SAME_SITE_LAX_COMMENT = SAME_SITE_COMMENT + "LAX__"; public static final String SAME_SITE_LAX_COMMENT = SAME_SITE_COMMENT + "LAX__";
private static final String SAME_SITE_STRICT_COMMENT = SAME_SITE_COMMENT + "STRICT__"; public static final String SAME_SITE_STRICT_COMMENT = SAME_SITE_COMMENT + "STRICT__";
public enum SameSite public enum SameSite
{ {
@ -431,17 +431,17 @@ public class HttpCookie
{ {
if (comment != null) if (comment != null)
{ {
if (comment.contains(SAME_SITE_NONE_COMMENT)) if (comment.contains(SAME_SITE_STRICT_COMMENT))
{ {
return SameSite.NONE; return SameSite.STRICT;
} }
if (comment.contains(SAME_SITE_LAX_COMMENT)) if (comment.contains(SAME_SITE_LAX_COMMENT))
{ {
return SameSite.LAX; return SameSite.LAX;
} }
if (comment.contains(SAME_SITE_STRICT_COMMENT)) if (comment.contains(SAME_SITE_NONE_COMMENT))
{ {
return SameSite.STRICT; return SameSite.NONE;
} }
} }
@ -465,6 +465,44 @@ public class HttpCookie
return strippedComment.length() == 0 ? null : strippedComment; return strippedComment.length() == 0 ? null : strippedComment;
} }
public static String getCommentWithAttributes(String comment, boolean httpOnly, SameSite sameSite)
{
if (comment == null && sameSite == null)
return null;
StringBuilder builder = new StringBuilder();
if (StringUtil.isNotBlank(comment))
{
comment = getCommentWithoutAttributes(comment);
if (StringUtil.isNotBlank(comment))
builder.append(comment);
}
if (httpOnly)
builder.append(HTTP_ONLY_COMMENT);
if (sameSite != null)
{
switch (sameSite)
{
case NONE:
builder.append(SAME_SITE_NONE_COMMENT);
break;
case STRICT:
builder.append(SAME_SITE_STRICT_COMMENT);
break;
case LAX:
builder.append(SAME_SITE_LAX_COMMENT);
break;
default:
throw new IllegalArgumentException(sameSite.toString());
}
}
if (builder.length() == 0)
return null;
return builder.toString();
}
public static class SetCookieHttpField extends HttpField public static class SetCookieHttpField extends HttpField
{ {
final HttpCookie _cookie; final HttpCookie _cookie;

View File

@ -18,17 +18,25 @@
package org.eclipse.jetty.http; package org.eclipse.jetty.http;
import java.util.stream.Stream;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
public class HttpCookieTest public class HttpCookieTest
{ {
@ -93,7 +101,6 @@ public class HttpCookieTest
httpCookie = new HttpCookie("everything", "value", "domain", "path", 0, true, true, null, -1); httpCookie = new HttpCookie("everything", "value", "domain", "path", 0, true, true, null, -1);
assertEquals("everything=value; Path=path; Domain=domain; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Secure; HttpOnly", httpCookie.getRFC6265SetCookie()); assertEquals("everything=value; Path=path; Domain=domain; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Secure; HttpOnly", httpCookie.getRFC6265SetCookie());
httpCookie = new HttpCookie("everything", "value", "domain", "path", 0, true, true, null, -1, HttpCookie.SameSite.NONE); httpCookie = new HttpCookie("everything", "value", "domain", "path", 0, true, true, null, -1, HttpCookie.SameSite.NONE);
assertEquals("everything=value; Path=path; Domain=domain; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Secure; HttpOnly; SameSite=None", httpCookie.getRFC6265SetCookie()); assertEquals("everything=value; Path=path; Domain=domain; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Secure; HttpOnly; SameSite=None", httpCookie.getRFC6265SetCookie());
@ -102,8 +109,11 @@ public class HttpCookieTest
httpCookie = new HttpCookie("everything", "value", "domain", "path", 0, true, true, null, -1, HttpCookie.SameSite.STRICT); httpCookie = new HttpCookie("everything", "value", "domain", "path", 0, true, true, null, -1, HttpCookie.SameSite.STRICT);
assertEquals("everything=value; Path=path; Domain=domain; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Secure; HttpOnly; SameSite=Strict", httpCookie.getRFC6265SetCookie()); assertEquals("everything=value; Path=path; Domain=domain; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Secure; HttpOnly; SameSite=Strict", httpCookie.getRFC6265SetCookie());
}
String[] badNameExamples = { public static Stream<String> rfc6265BadNameSource()
{
return Stream.of(
"\"name\"", "\"name\"",
"name\t", "name\t",
"na me", "na me",
@ -113,25 +123,32 @@ public class HttpCookieTest
"{name}", "{name}",
"[name]", "[name]",
"\"" "\""
}; );
}
for (String badNameExample : badNameExamples) @ParameterizedTest
{ @MethodSource("rfc6265BadNameSource")
try public void testSetRFC6265CookieBadName(String badNameExample)
{
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() ->
{ {
httpCookie = new HttpCookie(badNameExample, "value", null, "/", 1, true, true, null, -1); HttpCookie httpCookie = new HttpCookie(badNameExample, "value", null, "/", 1, true, true, null, -1);
httpCookie.getRFC6265SetCookie(); httpCookie.getRFC6265SetCookie();
fail(badNameExample); });
} // make sure that exception mentions just how mad of a name it truly is
catch (IllegalArgumentException ex) assertThat("message", ex.getMessage(),
{ allOf(
// System.err.printf("%s: %s%n", ex.getClass().getSimpleName(), ex.getMessage()); // violation of Cookie spec
assertThat("Testing bad name: [" + badNameExample + "]", ex.getMessage(), containsString("RFC6265"),
allOf(containsString("RFC6265"), containsString("RFC2616"))); // violation of HTTP spec
} containsString("RFC2616")
} ));
}
String[] badValueExamples = { public static Stream<String> rfc6265BadValueSource()
{
return Stream.of(
"va\tlue", "va\tlue",
"\t", "\t",
"value\u0000", "value\u0000",
@ -143,39 +160,44 @@ public class HttpCookieTest
"val\\ue", "val\\ue",
"val\"ue", "val\"ue",
"\"" "\""
}; );
}
for (String badValueExample : badValueExamples) @ParameterizedTest
{ @MethodSource("rfc6265BadValueSource")
try public void testSetRFC6265CookieBadValue(String badValueExample)
{
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() ->
{ {
httpCookie = new HttpCookie("name", badValueExample, null, "/", 1, true, true, null, -1); HttpCookie httpCookie = new HttpCookie("name", badValueExample, null, "/", 1, true, true, null, -1);
httpCookie.getRFC6265SetCookie(); httpCookie.getRFC6265SetCookie();
fail(); });
} assertThat("message", ex.getMessage(), containsString("RFC6265"));
catch (IllegalArgumentException ex) }
{
// System.err.printf("%s: %s%n", ex.getClass().getSimpleName(), ex.getMessage());
assertThat("Testing bad value [" + badValueExample + "]", ex.getMessage(), Matchers.containsString("RFC6265"));
}
}
String[] goodNameExamples = { public static Stream<String> rfc6265GoodNameSource()
{
return Stream.of(
"name", "name",
"n.a.m.e", "n.a.m.e",
"na-me", "na-me",
"+name", "+name",
"na*me", "na*me",
"na$me", "na$me",
"#name" "#name");
}; }
for (String goodNameExample : goodNameExamples) @ParameterizedTest
{ @MethodSource("rfc6265GoodNameSource")
httpCookie = new HttpCookie(goodNameExample, "value", null, "/", 1, true, true, null, -1); public void testSetRFC6265CookieGoodName(String goodNameExample)
// should not throw an exception {
} new HttpCookie(goodNameExample, "value", null, "/", 1, true, true, null, -1);
// should not throw an exception
}
public static Stream<String> rfc6265GoodValueSource()
{
String[] goodValueExamples = { String[] goodValueExamples = {
"value", "value",
"", "",
@ -185,37 +207,150 @@ public class HttpCookieTest
"val/ue", "val/ue",
"v.a.l.u.e" "v.a.l.u.e"
}; };
return Stream.of(goodValueExamples);
}
for (String goodValueExample : goodValueExamples) @ParameterizedTest
@MethodSource("rfc6265GoodValueSource")
public void testSetRFC6265CookieGoodValue(String goodValueExample)
{
new HttpCookie("name", goodValueExample, null, "/", 1, true, true, null, -1);
// should not throw an exception
}
@ParameterizedTest
@ValueSource(strings = {
"__HTTP_ONLY__",
"__HTTP_ONLY__comment",
"comment__HTTP_ONLY__"
})
public void testIsHttpOnlyInCommentTrue(String comment)
{
assertTrue(HttpCookie.isHttpOnlyInComment(comment), "Comment \"" + comment + "\"");
}
@ParameterizedTest
@ValueSource(strings = {
"comment",
"",
"__",
"__HTTP__ONLY__",
"__http_only__",
"HTTP_ONLY",
"__HTTP__comment__ONLY__"
})
public void testIsHttpOnlyInCommentFalse(String comment)
{
assertFalse(HttpCookie.isHttpOnlyInComment(comment), "Comment \"" + comment + "\"");
}
@ParameterizedTest
@ValueSource(strings = {
"__SAME_SITE_NONE__",
"__SAME_SITE_NONE____SAME_SITE_NONE__"
})
public void testGetSameSiteFromCommentNONE(String comment)
{
assertEquals(HttpCookie.getSameSiteFromComment(comment), HttpCookie.SameSite.NONE, "Comment \"" + comment + "\"");
}
@ParameterizedTest
@ValueSource(strings = {
"__SAME_SITE_LAX__",
"__SAME_SITE_LAX____SAME_SITE_NONE__",
"__SAME_SITE_NONE____SAME_SITE_LAX__",
"__SAME_SITE_LAX____SAME_SITE_NONE__"
})
public void testGetSameSiteFromCommentLAX(String comment)
{
assertEquals(HttpCookie.getSameSiteFromComment(comment), HttpCookie.SameSite.LAX, "Comment \"" + comment + "\"");
}
@ParameterizedTest
@ValueSource(strings = {
"__SAME_SITE_STRICT__",
"__SAME_SITE_NONE____SAME_SITE_STRICT____SAME_SITE_LAX__",
"__SAME_SITE_STRICT____SAME_SITE_LAX____SAME_SITE_NONE__",
"__SAME_SITE_STRICT____SAME_SITE_STRICT__"
})
public void testGetSameSiteFromCommentSTRICT(String comment)
{
assertEquals(HttpCookie.getSameSiteFromComment(comment), HttpCookie.SameSite.STRICT, "Comment \"" + comment + "\"");
}
/**
* A comment that does not have a declared SamesSite attribute defined
*/
@ParameterizedTest
@ValueSource(strings = {
"__HTTP_ONLY__",
"comment",
// not jetty attributes
"SameSite=None",
"SameSite=Lax",
"SameSite=Strict",
// incomplete jetty attributes
"SAME_SITE_NONE",
"SAME_SITE_LAX",
"SAME_SITE_STRICT",
})
public void testGetSameSiteFromCommentUndefined(String comment)
{
assertNull(HttpCookie.getSameSiteFromComment(comment), "Comment \"" + comment + "\"");
}
public static Stream<Arguments> getCommentWithoutAttributesSource()
{
return Stream.of(
// normal - only attribute comment
Arguments.of("__SAME_SITE_LAX__", null),
// normal - no attribute comment
Arguments.of("comment", "comment"),
// mixed - attributes at end
Arguments.of("comment__SAME_SITE_NONE__", "comment"),
Arguments.of("comment__HTTP_ONLY____SAME_SITE_NONE__", "comment"),
// mixed - attributes at start
Arguments.of("__SAME_SITE_NONE__comment", "comment"),
Arguments.of("__HTTP_ONLY____SAME_SITE_NONE__comment", "comment"),
// mixed - attributes at start and end
Arguments.of("__SAME_SITE_NONE__comment__HTTP_ONLY__", "comment"),
Arguments.of("__HTTP_ONLY__comment__SAME_SITE_NONE__", "comment")
);
}
@ParameterizedTest
@MethodSource("getCommentWithoutAttributesSource")
public void testGetCommentWithoutAttributes(String rawComment, String expectedComment)
{
String actualComment = HttpCookie.getCommentWithoutAttributes(rawComment);
if (expectedComment == null)
{ {
httpCookie = new HttpCookie("name", goodValueExample, null, "/", 1, true, true, null, -1); assertNull(actualComment);
// should not throw an exception }
else
{
assertEquals(actualComment, expectedComment);
} }
} }
@Test @Test
public void testGetHttpOnlyFromComment() public void testGetCommentWithAttributes()
{ {
assertTrue(HttpCookie.isHttpOnlyInComment("__HTTP_ONLY__")); assertThat(HttpCookie.getCommentWithAttributes(null, false, null), nullValue());
assertTrue(HttpCookie.isHttpOnlyInComment("__HTTP_ONLY__comment")); assertThat(HttpCookie.getCommentWithAttributes("", false, null), nullValue());
assertFalse(HttpCookie.isHttpOnlyInComment("comment")); assertThat(HttpCookie.getCommentWithAttributes("hello", false, null), is("hello"));
}
@Test assertThat(HttpCookie.getCommentWithAttributes(null, true, HttpCookie.SameSite.STRICT),
public void testGetSameSiteFromComment() is("__HTTP_ONLY____SAME_SITE_STRICT__"));
{ assertThat(HttpCookie.getCommentWithAttributes("", true, HttpCookie.SameSite.NONE),
assertEquals(HttpCookie.getSameSiteFromComment("__SAME_SITE_NONE__"), HttpCookie.SameSite.NONE); is("__HTTP_ONLY____SAME_SITE_NONE__"));
assertEquals(HttpCookie.getSameSiteFromComment("__SAME_SITE_LAX__"), HttpCookie.SameSite.LAX); assertThat(HttpCookie.getCommentWithAttributes("hello", true, HttpCookie.SameSite.LAX),
assertEquals(HttpCookie.getSameSiteFromComment("__SAME_SITE_STRICT__"), HttpCookie.SameSite.STRICT); is("hello__HTTP_ONLY____SAME_SITE_LAX__"));
assertEquals(HttpCookie.getSameSiteFromComment("__SAME_SITE_NONE____SAME_SITE_STRICT__"), HttpCookie.SameSite.NONE);
assertNull(HttpCookie.getSameSiteFromComment("comment"));
}
@Test assertThat(HttpCookie.getCommentWithAttributes("__HTTP_ONLY____SAME_SITE_LAX__", false, null), nullValue());
public void getCommentWithoutAttributes() assertThat(HttpCookie.getCommentWithAttributes("__HTTP_ONLY____SAME_SITE_LAX__", true, HttpCookie.SameSite.NONE),
{ is("__HTTP_ONLY____SAME_SITE_NONE__"));
assertEquals(HttpCookie.getCommentWithoutAttributes("comment__SAME_SITE_NONE__"), "comment"); assertThat(HttpCookie.getCommentWithAttributes("__HTTP_ONLY____SAME_SITE_LAX__hello", true, HttpCookie.SameSite.LAX),
assertEquals(HttpCookie.getCommentWithoutAttributes("comment__HTTP_ONLY____SAME_SITE_NONE__"), "comment"); is("hello__HTTP_ONLY____SAME_SITE_LAX__"));
assertNull(HttpCookie.getCommentWithoutAttributes("__SAME_SITE_LAX__"));
} }
} }

View File

@ -530,6 +530,16 @@ public class SessionHandler extends ScopedHandler
return _httpOnly; return _httpOnly;
} }
/**
* @return The sameSite setting for session cookies or null for no setting
* @see HttpCookie#getSameSite()
*/
@ManagedAttribute("SameSite setting for session cookies")
public HttpCookie.SameSite getSameSite()
{
return HttpCookie.getSameSiteFromComment(_sessionComment);
}
/** /**
* Returns the <code>HttpSession</code> with the given session id * Returns the <code>HttpSession</code> with the given session id
* *
@ -650,30 +660,18 @@ public class SessionHandler extends ScopedHandler
sessionPath = (StringUtil.isEmpty(sessionPath)) ? "/" : sessionPath; sessionPath = (StringUtil.isEmpty(sessionPath)) ? "/" : sessionPath;
String id = getExtendedId(session); String id = getExtendedId(session);
HttpCookie cookie = null; HttpCookie cookie = null;
if (_sessionComment == null)
{ cookie = new HttpCookie(
cookie = new HttpCookie( _cookieConfig.getName(),
_cookieConfig.getName(), id,
id, _cookieConfig.getDomain(),
_cookieConfig.getDomain(), sessionPath,
sessionPath, _cookieConfig.getMaxAge(),
_cookieConfig.getMaxAge(), _cookieConfig.isHttpOnly(),
_cookieConfig.isHttpOnly(), _cookieConfig.isSecure() || (isSecureRequestOnly() && requestIsSecure),
_cookieConfig.isSecure() || (isSecureRequestOnly() && requestIsSecure)); HttpCookie.getCommentWithoutAttributes(_cookieConfig.getComment()),
} 0,
else HttpCookie.getSameSiteFromComment(_cookieConfig.getComment()));
{
cookie = new HttpCookie(
_cookieConfig.getName(),
id,
_cookieConfig.getDomain(),
sessionPath,
_cookieConfig.getMaxAge(),
_cookieConfig.isHttpOnly(),
_cookieConfig.isSecure() || (isSecureRequestOnly() && requestIsSecure),
_sessionComment,
1);
}
return cookie; return cookie;
} }
@ -814,13 +812,28 @@ public class SessionHandler extends ScopedHandler
} }
/** /**
* @param httpOnly The httpOnly to set. * Set if Session cookies should use HTTP Only
* @param httpOnly True if cookies should be HttpOnly.
* @see HttpCookie
*/ */
public void setHttpOnly(boolean httpOnly) public void setHttpOnly(boolean httpOnly)
{ {
_httpOnly = httpOnly; _httpOnly = httpOnly;
} }
/**
* Set Session cookie sameSite mode.
* Currently this is encoded in the session comment until sameSite is supported by {@link SessionCookieConfig}
* @param sameSite The sameSite setting for Session cookies (or null for no sameSite setting)
*/
public void setSameSite(HttpCookie.SameSite sameSite)
{
// Encode in comment whilst not supported by SessionConfig, so that it can be set/saved in
// web.xml and quickstart.
// Always pass false for httpOnly as it has it's own setter.
_sessionComment = HttpCookie.getCommentWithAttributes(_sessionComment, false, sameSite);
}
/** /**
* @param metaManager The metaManager used for cross context session management. * @param metaManager The metaManager used for cross context session management.
*/ */
@ -1364,6 +1377,8 @@ public class SessionHandler extends ScopedHandler
* CookieConfig * CookieConfig
* *
* Implementation of the javax.servlet.SessionCookieConfig. * Implementation of the javax.servlet.SessionCookieConfig.
* SameSite configuration can be achieved by using setComment
* @see HttpCookie
*/ */
public final class CookieConfig implements SessionCookieConfig public final class CookieConfig implements SessionCookieConfig
{ {