diff --git a/framework/src/play/data/validation/ValidationPlugin.java b/framework/src/play/data/validation/ValidationPlugin.java index bd091da234..c31fbf47f0 100644 --- a/framework/src/play/data/validation/ValidationPlugin.java +++ b/framework/src/play/data/validation/ValidationPlugin.java @@ -160,7 +160,7 @@ static void save() { if (Validation.errors().isEmpty()) { // Only send "delete cookie" header when the cookie was present in the request if(Http.Request.current().cookies.containsKey(Scope.COOKIE_PREFIX + "_ERRORS") || !Scope.SESSION_SEND_ONLY_IF_CHANGED) { - Http.Response.current().setCookie(Scope.COOKIE_PREFIX + "_ERRORS", "", null, "/", 0, Scope.COOKIE_SECURE, Scope.SESSION_HTTPONLY); + Http.Response.current().setCookie(Scope.COOKIE_PREFIX + "_ERRORS", "", null, "/", 0, Scope.COOKIE_SECURE, Scope.SESSION_HTTPONLY, null); } return; } @@ -180,7 +180,7 @@ static void save() { } } String errorsData = URLEncoder.encode(errors.toString(), StandardCharsets.UTF_8); - Http.Response.current().setCookie(Scope.COOKIE_PREFIX + "_ERRORS", errorsData, null, "/", null, Scope.COOKIE_SECURE, Scope.SESSION_HTTPONLY); + Http.Response.current().setCookie(Scope.COOKIE_PREFIX + "_ERRORS", errorsData, null, "/", null, Scope.COOKIE_SECURE, Scope.SESSION_HTTPONLY, null); } catch (Exception e) { throw new UnexpectedException("Errors serializationProblem", e); } diff --git a/framework/src/play/i18n/Lang.java b/framework/src/play/i18n/Lang.java index 6c174bd948..cb2dd20ae8 100644 --- a/framework/src/play/i18n/Lang.java +++ b/framework/src/play/i18n/Lang.java @@ -82,7 +82,7 @@ public static void change(String locale) { Response response = Response.current(); if (response != null) { // We have a current response in scope - set the language-cookie to store the selected language for the next requests - response.setCookie(Play.configuration.getProperty("application.lang.cookie", "PLAY_LANG"), locale, null, "/", null, Scope.COOKIE_SECURE); + response.setCookie(Play.configuration.getProperty("application.lang.cookie", "PLAY_LANG"), locale, null, "/", null, Scope.COOKIE_SECURE, Scope.SESSION_SAMESITE); } } @@ -155,7 +155,7 @@ private static void resolveFrom(Request request) { return; } // could not use locale from cookie - clear the locale-cookie - Response.current().setCookie(cn, "", null, "/", null, Scope.COOKIE_SECURE); + Response.current().setCookie(cn, "", null, "/", null, Scope.COOKIE_SECURE, Scope.SESSION_SAMESITE); } diff --git a/framework/src/play/mvc/CookieSessionStore.java b/framework/src/play/mvc/CookieSessionStore.java index 30f41f6582..32d213e9f9 100644 --- a/framework/src/play/mvc/CookieSessionStore.java +++ b/framework/src/play/mvc/CookieSessionStore.java @@ -75,7 +75,7 @@ public void save(Session session) { if (session.isEmpty()) { // The session is empty: delete the cookie if (Http.Request.current().cookies.containsKey(COOKIE_PREFIX + "_SESSION") || !SESSION_SEND_ONLY_IF_CHANGED) { - Http.Response.current().setCookie(COOKIE_PREFIX + "_SESSION", "", null, "/", 0, COOKIE_SECURE, SESSION_HTTPONLY); + Http.Response.current().setCookie(COOKIE_PREFIX + "_SESSION", "", null, "/", 0, COOKIE_SECURE, SESSION_HTTPONLY, SESSION_SAMESITE); } return; } @@ -84,10 +84,10 @@ public void save(Session session) { String sign = Crypto.sign(sessionData, Play.secretKey.getBytes()); if (COOKIE_EXPIRE == null) { Http.Response.current().setCookie(COOKIE_PREFIX + "_SESSION", sign + "-" + sessionData, null, "/", null, COOKIE_SECURE, - SESSION_HTTPONLY); + SESSION_HTTPONLY, SESSION_SAMESITE); } else { Http.Response.current().setCookie(COOKIE_PREFIX + "_SESSION", sign + "-" + sessionData, null, "/", - Time.parseDuration(COOKIE_EXPIRE), COOKIE_SECURE, SESSION_HTTPONLY); + Time.parseDuration(COOKIE_EXPIRE), COOKIE_SECURE, SESSION_HTTPONLY, SESSION_SAMESITE); } } catch (Exception e) { throw new UnexpectedException("Session serializationProblem", e); diff --git a/framework/src/play/mvc/Http.java b/framework/src/play/mvc/Http.java index 5ec27af3b0..c7bc3e6c53 100644 --- a/framework/src/play/mvc/Http.java +++ b/framework/src/play/mvc/Http.java @@ -161,6 +161,10 @@ public static class Cookie implements Serializable { * See http://www.owasp.org/index.php/HttpOnly */ public boolean httpOnly = false; + /** + * See https://owasp.org/www-community/SameSite + */ + public SameSite sameSite; } /** @@ -701,8 +705,8 @@ public void setContentTypeIfNotSet(String contentType) { * @param value * Cookie value */ - public void setCookie(String name, String value) { - setCookie(name, value, null, "/", null, false); + public void setCookie(String name, String value, SameSite sameSite) { + setCookie(name, value, null, "/", null, false, sameSite); } /** @@ -724,7 +728,7 @@ public void removeCookie(String name) { * cookie path */ public void removeCookie(String name, String path) { - setCookie(name, "", null, path, 0, false); + setCookie(name, "", null, path, 0, false, null); } /** @@ -737,15 +741,15 @@ public void removeCookie(String name, String path) { * @param duration * the cookie duration (Ex: 3d) */ - public void setCookie(String name, String value, String duration) { - setCookie(name, value, null, "/", Time.parseDuration(duration), false); + public void setCookie(String name, String value, String duration, SameSite sameSite) { + setCookie(name, value, null, "/", Time.parseDuration(duration), false, sameSite); } - public void setCookie(String name, String value, String domain, String path, Integer maxAge, boolean secure) { - setCookie(name, value, domain, path, maxAge, secure, false); + public void setCookie(String name, String value, String domain, String path, Integer maxAge, boolean secure, SameSite sameSite) { + setCookie(name, value, domain, path, maxAge, secure, false, sameSite); } - public void setCookie(String name, String value, String domain, String path, Integer maxAge, boolean secure, boolean httpOnly) { + public void setCookie(String name, String value, String domain, String path, Integer maxAge, boolean secure, boolean httpOnly, SameSite sameSite) { path = Play.ctxPath + path; if (cookies.containsKey(name) && cookies.get(name).path.equals(path) && ((cookies.get(name).domain == null && domain == null) || (cookies.get(name).domain.equals(domain)))) { @@ -759,6 +763,7 @@ public void setCookie(String name, String value, String domain, String path, Int cookie.path = path; cookie.secure = secure; cookie.httpOnly = httpOnly; + cookie.sameSite = sameSite; if (domain != null) { cookie.domain = domain; } else { @@ -1012,4 +1017,20 @@ public WebSocketFrame(byte[] data) { public static class WebSocketClose extends WebSocketEvent { } + + public enum SameSite { + STRICT("Strict"), + LAX("Lax"), + NONE("None"); + + private final String value; + + SameSite(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } } diff --git a/framework/src/play/mvc/Scope.java b/framework/src/play/mvc/Scope.java index 4e58f6c3b7..cb69d00edd 100644 --- a/framework/src/play/mvc/Scope.java +++ b/framework/src/play/mvc/Scope.java @@ -32,6 +32,8 @@ public class Scope { .equals("true"); public static boolean SESSION_SEND_ONLY_IF_CHANGED = Play.configuration .getProperty("application.session.sendOnlyIfChanged", "false").toLowerCase().equals("true"); + public static final Http.SameSite SESSION_SAMESITE = Play.configuration.getProperty("application.session.cookie.sameSite") != null ? + Http.SameSite.valueOf(Play.configuration.getProperty("application.session.cookie.sameSite").toUpperCase()) : null; public static final SessionStore sessionStore = createSessionStore(); @@ -78,13 +80,13 @@ void save() { } if (out.isEmpty()) { if (Http.Request.current().cookies.containsKey(COOKIE_PREFIX + "_FLASH") || !SESSION_SEND_ONLY_IF_CHANGED) { - Http.Response.current().setCookie(COOKIE_PREFIX + "_FLASH", "", null, "/", 0, COOKIE_SECURE, SESSION_HTTPONLY); + Http.Response.current().setCookie(COOKIE_PREFIX + "_FLASH", "", null, "/", 0, COOKIE_SECURE, SESSION_HTTPONLY, SESSION_SAMESITE); } return; } try { String flashData = CookieDataCodec.encode(out); - Http.Response.current().setCookie(COOKIE_PREFIX + "_FLASH", flashData, null, "/", null, COOKIE_SECURE, SESSION_HTTPONLY); + Http.Response.current().setCookie(COOKIE_PREFIX + "_FLASH", flashData, null, "/", null, COOKIE_SECURE, SESSION_HTTPONLY, SESSION_SAMESITE); } catch (Exception e) { throw new UnexpectedException("Flash serializationProblem", e); } diff --git a/framework/src/play/server/PlayHandler.java b/framework/src/play/server/PlayHandler.java index 5a1291e0b8..2dfa759adb 100644 --- a/framework/src/play/server/PlayHandler.java +++ b/framework/src/play/server/PlayHandler.java @@ -353,7 +353,11 @@ protected static void addToResponse(Response response, HttpResponse nettyRespons c.setMaxAge(cookie.maxAge); } c.setHttpOnly(cookie.httpOnly); - nettyResponse.headers().add(SET_COOKIE, ServerCookieEncoder.STRICT.encode(c)); + String encodedCookie = ServerCookieEncoder.STRICT.encode(c); + if (cookie.sameSite != null) { + encodedCookie += "; SameSite=" + cookie.sameSite.getValue(); + } + nettyResponse.headers().add(SET_COOKIE, encodedCookie); } if (!response.headers.containsKey(CACHE_CONTROL) && !response.headers.containsKey(EXPIRES) @@ -767,8 +771,11 @@ public static void serve500(Exception e, ChannelHandlerContext ctx, HttpRequest c.setMaxAge(cookie.maxAge); } c.setHttpOnly(cookie.httpOnly); - - nettyResponse.headers().add(SET_COOKIE, ServerCookieEncoder.STRICT.encode(c)); + String encodedCookie = ServerCookieEncoder.STRICT.encode(c); + if (cookie.sameSite != null) { + encodedCookie += "; SameSite=" + cookie.sameSite.getValue(); + } + nettyResponse.headers().add(SET_COOKIE, encodedCookie); } } catch (Exception exx) { diff --git a/framework/test-src/play/mvc/HttpResponseTest.java b/framework/test-src/play/mvc/HttpResponseTest.java index 662e5c0fb2..a992efbb97 100644 --- a/framework/test-src/play/mvc/HttpResponseTest.java +++ b/framework/test-src/play/mvc/HttpResponseTest.java @@ -9,12 +9,30 @@ public class HttpResponseTest { public void verifyDefaultCookieDomain() { Http.Cookie.defaultDomain = null; Http.Response response = new Http.Response(); - response.setCookie("testCookie", "testValue"); + response.setCookie("testCookie", "testValue", null); assertThat(response.cookies.get("testCookie").domain).isNull(); Http.Cookie.defaultDomain = ".abc.com"; response = new Http.Response(); - response.setCookie("testCookie", "testValue"); + response.setCookie("testCookie", "testValue", null); assertThat(response.cookies.get("testCookie").domain).isEqualTo(".abc.com"); } + + @Test + public void verifySameSiteCookie() { + Http.Cookie.defaultDomain = null; + Http.Response response = new Http.Response(); + response.setCookie("testCookie", "testValue", null); + assertThat(response.cookies.get("testCookie").sameSite).isNull(); + + Http.Cookie.defaultDomain = ".abc.com"; + response = new Http.Response(); + response.setCookie("testCookie", "testValue", Http.SameSite.LAX); + assertThat(response.cookies.get("testCookie").sameSite).isEqualTo(Http.SameSite.LAX); + + Http.Cookie.defaultDomain = ".abc.com"; + response = new Http.Response(); + response.setCookie("testCookie", "testValue", Http.SameSite.STRICT); + assertThat(response.cookies.get("testCookie").sameSite).isEqualTo(Http.SameSite.STRICT); + } } diff --git a/modules/secure/app/controllers/Secure.java b/modules/secure/app/controllers/Secure.java index 77e95000f6..a4fa22bee2 100644 --- a/modules/secure/app/controllers/Secure.java +++ b/modules/secure/app/controllers/Secure.java @@ -87,7 +87,7 @@ public static void authenticate(@Required String username, String password, bool Date expiration = new Date(); String duration = Play.configuration.getProperty("secure.rememberme.duration","30d"); expiration.setTime(expiration.getTime() + ((long)Time.parseDuration(duration)) * 1000L ); - response.setCookie("rememberme", Crypto.sign(username + "-" + expiration.getTime()) + "-" + username + "-" + expiration.getTime(), duration); + response.setCookie("rememberme", Crypto.sign(username + "-" + expiration.getTime()) + "-" + username + "-" + expiration.getTime(), duration, null); } // Redirect to the original URL (or /) diff --git a/resources/application-skel/conf/application.conf b/resources/application-skel/conf/application.conf index eec69ee9c2..9bf2e0e827 100644 --- a/resources/application-skel/conf/application.conf +++ b/resources/application-skel/conf/application.conf @@ -48,6 +48,7 @@ date.format=yyyy-MM-dd # application.session.cookie=PLAY # application.session.maxAge=1h # application.session.secure=false +# application.session.cookie.sameSite=lax # Session/Cookie sharing between subdomain # ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/samples-and-tests/just-test-cases/app/controllers/Application.java b/samples-and-tests/just-test-cases/app/controllers/Application.java index d1e138a9ce..ef0aefa948 100644 --- a/samples-and-tests/just-test-cases/app/controllers/Application.java +++ b/samples-and-tests/just-test-cases/app/controllers/Application.java @@ -398,7 +398,7 @@ public static void writeChunks2() { public static void makeSureCookieSaved(){ if(request.cookies!=null && request.cookies.get("PLAY_TEST")!=null){ - response.setCookie("PLAY_TEST", request.cookies.get("PLAY_TEST").value); + response.setCookie("PLAY_TEST", request.cookies.get("PLAY_TEST").value, null); } renderText("OK"); }