diff --git a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java index b65df60254c6..c06ecc02877c 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java @@ -50,14 +50,14 @@ public final class ResponseCookie extends HttpCookie { private final boolean partitioned; @Nullable - private final String sameSite; + private final SameSite sameSite; /** * Private constructor. See {@link #from(String, String)}. */ private ResponseCookie(String name, @Nullable String value, Duration maxAge, @Nullable String domain, - @Nullable String path, boolean secure, boolean httpOnly, boolean partitioned, @Nullable String sameSite) { + @Nullable String path, boolean secure, boolean httpOnly, boolean partitioned, @Nullable SameSite sameSite) { super(name, value); Assert.notNull(maxAge, "Max age must not be null"); @@ -137,7 +137,11 @@ public boolean isPartitioned() { */ @Nullable public String getSameSite() { - return this.sameSite; + if(ObjectUtils.isEmpty(this.sameSite)) { + return null; + } + + return this.sameSite.getValue(); } /** @@ -196,8 +200,8 @@ public String toString() { if (this.partitioned) { sb.append("; Partitioned"); } - if (StringUtils.hasText(this.sameSite)) { - sb.append("; SameSite=").append(this.sameSite); + if (!ObjectUtils.isEmpty(this.sameSite)) { + sb.append("; SameSite=").append(this.sameSite.getValue()); } return sb.toString(); } @@ -305,6 +309,8 @@ public interface ResponseCookieBuilder { */ ResponseCookieBuilder sameSite(@Nullable String sameSite); + ResponseCookieBuilder sameSite(@Nullable SameSite sameSite); + /** * Create the HttpCookie. */ @@ -423,7 +429,7 @@ private static class DefaultResponseCookieBuilder implements ResponseCookieBuild private boolean partitioned; @Nullable - private String sameSite; + private SameSite sameSite; public DefaultResponseCookieBuilder(String name, @Nullable String value, boolean lenient) { this.name = name; @@ -494,6 +500,17 @@ public ResponseCookieBuilder partitioned(boolean partitioned) { @Override public ResponseCookieBuilder sameSite(@Nullable String sameSite) { + if(!StringUtils.hasText(sameSite)) { + this.sameSite = null; + return this; + } + + this.sameSite = SameSite.fromSuffix(sameSite); + return this; + } + + @Override + public ResponseCookieBuilder sameSite(@Nullable SameSite sameSite) { this.sameSite = sameSite; return this; } @@ -505,4 +522,82 @@ public ResponseCookie build() { } } + /** + * Enumeration representing the possible values for the "SameSite" attribute of an HTTP cookie. + * This attribute restricts how cookies are sent with cross-site requests, providing three levels of restriction: + *
This enum is used to control the scope of cookies in relation to cross-site requests, + * providing enhanced security and privacy for web applications by mitigating cross-site request forgery (CSRF) attacks.
+ * + * @see The SameSite Attribute + */ + enum SameSite { + + /** + * "Strict" SameSite setting. Cookies are only sent in a "same-site" context. + * This setting provides the highest level of protection against cross-site attacks. + */ + STRICT("Strict"), + + /** + * "Lax" SameSite setting. Cookies are sent in a "same-site" context and with top-level navigations, + * but are not sent with third-party or subresource requests. + */ + LAX("Lax"), + + /** + * "None" SameSite setting. Cookies are sent in all contexts, including cross-site requests. + * This setting requires the cookie to be marked as "Secure" to be sent over HTTPS connections only. + */ + NONE("None"); + + private final String value; + + /** + * Constructor for initializing the enum with a specific name. + * + * @param value the name of the SameSite value + */ + + SameSite(String value) { + this.value = value; + } + + /** + * Returns the {@code SameSite} enum instance corresponding to the given string suffix. + * The input string is normalized by trimming and converting to uppercase before matching. + * + * @param suffix the string representation of the SameSite value + * @return the corresponding {@code SameSite} enum instance, or {@link #NONE} if the input is null, empty, or does not match any enum value. + */ + static SameSite fromSuffix(String suffix) { + if (!StringUtils.hasText(suffix)) { + return LAX; + } + + String normalizedInput = suffix.trim().toUpperCase(); + + for (SameSite sameSite : SameSite.values()) { + if (sameSite.value.equalsIgnoreCase(normalizedInput)) { + return sameSite; + } + } + + return LAX; + } + + /** + * Returns the value of the SameSite setting. + * + * @return the value of this SameSite setting. + */ + public String getValue() { + return this.value; + } + } } diff --git a/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java b/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java index e1e19dc6b4bd..e9335ea7a244 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseCookieTests.java @@ -37,7 +37,7 @@ void basic() { assertThat(ResponseCookie.from("id", "1fWa").build().toString()).isEqualTo("id=1fWa"); ResponseCookie cookie = ResponseCookie.from("id", "1fWa") - .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite("None") + .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite(ResponseCookie.SameSite.NONE) .build(); assertThat(cookie.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " + @@ -92,4 +92,46 @@ public void domainWithEmptyDoubleQuotes() { }); } + + @Test + void basicWithSameSiteEnum() { + ResponseCookie cookieStrict = ResponseCookie.from("id", "1fWa") + .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite(ResponseCookie.SameSite.STRICT) + .build(); + + assertThat(cookieStrict.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " + + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + + "Secure; HttpOnly; Partitioned; SameSite=Strict"); + + ResponseCookie cookieLax = ResponseCookie.from("id", "1fWa") + .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite(ResponseCookie.SameSite.LAX) + .build(); + + assertThat(cookieLax.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " + + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + + "Secure; HttpOnly; Partitioned; SameSite=Lax"); + + ResponseCookie cookieNone = ResponseCookie.from("id", "1fWa") + .domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite(ResponseCookie.SameSite.NONE) + .build(); + + assertThat(cookieNone.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " + + "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " + + "Secure; HttpOnly; Partitioned; SameSite=None"); + } + + @Test + void fromSuffixValidInputs() { + Arrays.asList("strict", "STRICT", "Strict"," strict ", " STRICT ", "StRiCt", "sTrIcT") + .forEach(suffix -> assertThat(ResponseCookie.SameSite.fromSuffix(suffix)).isEqualTo(ResponseCookie.SameSite.STRICT)); + + Arrays.asList("lax", "LAX", "Lax", " lax ", " LAX ", "lAx", "LaX") + .forEach(suffix -> assertThat(ResponseCookie.SameSite.fromSuffix(suffix)).isEqualTo(ResponseCookie.SameSite.LAX)); + + Arrays.asList("none", "NONE", " None ", " NONE ", "nOnE", "NoNe") + .forEach(suffix -> assertThat(ResponseCookie.SameSite.fromSuffix(suffix)).isEqualTo(ResponseCookie.SameSite.NONE)); + + Arrays.asList("", null, "XXX") + .forEach(suffix -> assertThat(ResponseCookie.SameSite.fromSuffix(suffix)).isEqualTo(ResponseCookie.SameSite.LAX)); + } }