Skip to content

Add SameSite enum support to ResponseCookie #33425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 101 additions & 6 deletions spring-web/src/main/java/org/springframework/http/ResponseCookie.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -305,6 +309,8 @@ public interface ResponseCookieBuilder {
*/
ResponseCookieBuilder sameSite(@Nullable String sameSite);

ResponseCookieBuilder sameSite(@Nullable SameSite sameSite);

/**
* Create the HttpCookie.
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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:
* <ul>
* <li>{@code Strict}: Cookies are only sent in a first-party context (i.e., same site as the request).</li>
* <li>{@code Lax}: Cookies are sent in a first-party context and with top-level navigation, but not with third-party requests.</li>
* <li>{@code None}: Cookies are sent in all contexts, including cross-site requests, but must be used with the "Secure" attribute.</li>
* </ul>
*
* <p>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.</p>
*
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-the-samesite-attribute">The SameSite Attribute</a>
*/
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; " +
Expand Down Expand Up @@ -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));
}
}
Loading