Add Partitioned cookie attribute support for servers

This commit adds support for the "Partitioned" cookie attribute in
WebFlux servers and the related testing infrastructure.
Note, Undertow does not support this feature at the moment.

Closes gh-31454
This commit is contained in:
Brian Clozel 2024-06-07 10:03:52 +02:00
parent 2aabe238c6
commit 7fc4937199
18 changed files with 178 additions and 10 deletions

View File

@ -98,6 +98,24 @@ public class MockCookie extends Cookie {
return getAttribute(SAME_SITE);
}
/**
* Set the "Partitioned" attribute for this cookie.
* @since 6.2
* @see <a href="https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1">The Partitioned attribute spec</a>
*/
public void setPartitioned(boolean partitioned) {
setAttribute("Partitioned", "");
}
/**
* Return whether the "Partitioned" attribute is set for this cookie.
* @since 6.2
* @see <a href="https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1">The Partitioned attribute spec</a>
*/
public boolean isPartitioned() {
return getAttribute("Partitioned") != null;
}
/**
* Factory method that parses the value of the supplied "Set-Cookie" header.
* @param setCookieHeader the "Set-Cookie" value; never {@code null} or empty
@ -146,6 +164,9 @@ public class MockCookie extends Cookie {
else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) {
cookie.setComment(extractAttributeValue(attribute, setCookieHeader));
}
else if (!attribute.isEmpty()) {
cookie.setAttribute(attribute, extractOptionalAttributeValue(attribute, setCookieHeader));
}
}
return cookie;
}
@ -157,6 +178,11 @@ public class MockCookie extends Cookie {
return nameAndValue[1];
}
private static String extractOptionalAttributeValue(String attribute, String header) {
String[] nameAndValue = attribute.split("=");
return nameAndValue.length == 2 ? nameAndValue[1] : "";
}
@Override
public void setAttribute(String name, @Nullable String value) {
if (EXPIRES.equalsIgnoreCase(name)) {
@ -176,6 +202,7 @@ public class MockCookie extends Cookie {
.append("Comment", getComment())
.append("Secure", getSecure())
.append("HttpOnly", isHttpOnly())
.append("Partitioned", isPartitioned())
.append(SAME_SITE, getSameSite())
.append("Max-Age", getMaxAge())
.append(EXPIRES, getAttribute(EXPIRES))

View File

@ -481,6 +481,9 @@ public class MockHttpServletResponse implements HttpServletResponse {
if (cookie.isHttpOnly()) {
buf.append("; HttpOnly");
}
if (cookie.getAttribute("Partitioned") != null) {
buf.append("; Partitioned");
}
if (cookie instanceof MockCookie mockCookie) {
if (StringUtils.hasText(mockCookie.getSameSite())) {
buf.append("; SameSite=").append(mockCookie.getSameSite());

View File

@ -197,6 +197,19 @@ public class CookieAssertions {
return this.responseSpec;
}
/**
* Assert a cookie's "Partitioned" attribute.
* @since 6.2
*/
public WebTestClient.ResponseSpec partitioned(String name, boolean expected) {
boolean isPartitioned = getCookie(name).isPartitioned();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " isPartitioned";
assertEquals(message, expected, isPartitioned);
});
return this.responseSpec;
}
/**
* Assert a cookie's "SameSite" attribute.
*/

View File

@ -209,6 +209,7 @@ public class MockMvcHttpConnector implements ClientHttpConnector {
.path(cookie.getPath())
.secure(cookie.getSecure())
.httpOnly(cookie.isHttpOnly())
.partitioned(cookie.getAttribute("Partitioned") != null)
.sameSite(cookie.getAttribute("samesite"))
.build();
clientResponse.getCookies().add(httpCookie.getName(), httpCookie);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2024 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.
@ -229,6 +229,17 @@ public class CookieResultMatchers {
};
}
/**
* Assert whether the cookie is partitioned.
* @since 6.2
*/
public ResultMatcher partitioned(String name, boolean partitioned) {
return result -> {
Cookie cookie = getCookie(result, name);
assertEquals("Response cookie '" + name + "' partitioned", partitioned, cookie.getAttribute("Partitioned") != null);
};
}
/**
* Assert a cookie's specified attribute with a Hamcrest {@link Matcher}.
* @param cookieAttribute the name of the Cookie attribute (case-insensitive)

View File

@ -157,6 +157,14 @@ class CookieResultMatchersDsl internal constructor (private val actions: ResultA
actions.andExpect(matchers.httpOnly(name, httpOnly))
}
/**
* @see CookieResultMatchers.partitioned
* @since 6.2
*/
fun partitioned(name: String, partitioned: Boolean) {
actions.andExpect(matchers.partitioned(name, partitioned))
}
/**
* @see CookieResultMatchers.attribute
* @since 6.0.8

View File

@ -71,7 +71,7 @@ class MockCookieTests {
@Test
void parseHeaderWithAttributes() {
MockCookie cookie = MockCookie.parse("SESSION=123; Domain=example.com; Max-Age=60; " +
"Expires=Tue, 8 Oct 2019 19:50:00 GMT; Path=/; Secure; HttpOnly; SameSite=Lax");
"Expires=Tue, 8 Oct 2019 19:50:00 GMT; Path=/; Secure; HttpOnly; Partitioned; SameSite=Lax");
assertCookie(cookie, "SESSION", "123");
assertThat(cookie.getDomain()).isEqualTo("example.com");
@ -79,6 +79,7 @@ class MockCookieTests {
assertThat(cookie.getPath()).isEqualTo("/");
assertThat(cookie.getSecure()).isTrue();
assertThat(cookie.isHttpOnly()).isTrue();
assertThat(cookie.isPartitioned()).isTrue();
assertThat(cookie.getExpires()).isEqualTo(ZonedDateTime.parse("Tue, 8 Oct 2019 19:50:00 GMT",
DateTimeFormatter.RFC_1123_DATE_TIME));
assertThat(cookie.getSameSite()).isEqualTo("Lax");
@ -203,4 +204,12 @@ class MockCookieTests {
assertThatThrownBy(() -> cookie.setAttribute("expires", "12345")).isInstanceOf(DateTimeParseException.class);
}
@Test
void setPartitioned() {
MockCookie cookie = new MockCookie("SESSION", "123");
cookie.setAttribute("Partitioned", "");
assertThat(cookie.isPartitioned()).isTrue();
}
}

View File

@ -274,12 +274,13 @@ class MockHttpServletResponseTests {
cookie.setMaxAge(0);
cookie.setSecure(true);
cookie.setHttpOnly(true);
cookie.setAttribute("Partitioned", "");
response.addCookie(cookie);
assertThat(response.getHeader(SET_COOKIE)).isEqualTo(("foo=bar; Path=/path; Domain=example.com; " +
"Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " +
"Secure; HttpOnly"));
"Secure; HttpOnly; Partitioned"));
}
@Test

View File

@ -37,7 +37,7 @@ import static org.mockito.Mockito.mock;
*
* @author Rossen Stoyanchev
*/
public class CookieAssertionTests {
public class CookieAssertionsTests {
private final ResponseCookie cookie = ResponseCookie.from("foo", "bar")
.maxAge(Duration.ofMinutes(30))
@ -45,6 +45,7 @@ public class CookieAssertionTests {
.path("/foo")
.secure(true)
.httpOnly(true)
.partitioned(true)
.sameSite("Lax")
.build();
@ -117,6 +118,12 @@ public class CookieAssertionTests {
assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.httpOnly("foo", false));
}
@Test
void partitioned() {
assertions.partitioned("foo", true);
assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.partitioned("foo", false));
}
@Test
void sameSite() {
assertions.sameSite("foo", "Lax");

View File

@ -47,6 +47,8 @@ public final class ResponseCookie extends HttpCookie {
private final boolean httpOnly;
private final boolean partitioned;
@Nullable
private final String sameSite;
@ -55,7 +57,7 @@ public final class ResponseCookie extends HttpCookie {
* 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, @Nullable String sameSite) {
@Nullable String path, boolean secure, boolean httpOnly, boolean partitioned, @Nullable String sameSite) {
super(name, value);
Assert.notNull(maxAge, "Max age must not be null");
@ -65,6 +67,7 @@ public final class ResponseCookie extends HttpCookie {
this.path = path;
this.secure = secure;
this.httpOnly = httpOnly;
this.partitioned = partitioned;
this.sameSite = sameSite;
Rfc6265Utils.validateCookieName(name);
@ -116,6 +119,15 @@ public final class ResponseCookie extends HttpCookie {
return this.httpOnly;
}
/**
* Return {@code true} if the cookie has the "Partitioned" attribute.
* @since 6.2
* @see <a href="https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1">The Partitioned attribute spec</a>
*/
public boolean isPartitioned() {
return this.partitioned;
}
/**
* Return the cookie "SameSite" attribute, or {@code null} if not set.
* <p>This limits the scope of the cookie such that it will only be attached to
@ -139,6 +151,7 @@ public final class ResponseCookie extends HttpCookie {
.path(this.path)
.secure(this.secure)
.httpOnly(this.httpOnly)
.partitioned(this.partitioned)
.sameSite(this.sameSite);
}
@ -180,6 +193,9 @@ public final class ResponseCookie extends HttpCookie {
if (this.httpOnly) {
sb.append("; HttpOnly");
}
if (this.partitioned) {
sb.append("; Partitioned");
}
if (StringUtils.hasText(this.sameSite)) {
sb.append("; SameSite=").append(this.sameSite);
}
@ -272,6 +288,13 @@ public final class ResponseCookie extends HttpCookie {
*/
ResponseCookieBuilder httpOnly(boolean httpOnly);
/**
* Add the "Partitioned" attribute to the cookie.
* @since 6.2
* @see <a href="https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1">The Partitioned attribute spec</a>
*/
ResponseCookieBuilder partitioned(boolean partitioned);
/**
* Add the "SameSite" attribute to the cookie.
* <p>This limits the scope of the cookie such that it will only be
@ -397,6 +420,8 @@ public final class ResponseCookie extends HttpCookie {
private boolean httpOnly;
private boolean partitioned;
@Nullable
private String sameSite;
@ -461,6 +486,12 @@ public final class ResponseCookie extends HttpCookie {
return this;
}
@Override
public ResponseCookieBuilder partitioned(boolean partitioned) {
this.partitioned = partitioned;
return this;
}
@Override
public ResponseCookieBuilder sameSite(@Nullable String sameSite) {
this.sameSite = sameSite;
@ -470,7 +501,7 @@ public final class ResponseCookie extends HttpCookie {
@Override
public ResponseCookie build() {
return new ResponseCookie(this.name, this.value, this.maxAge,
this.domain, this.path, this.secure, this.httpOnly, this.sameSite);
this.domain, this.path, this.secure, this.httpOnly, this.partitioned, this.sameSite);
}
}

View File

@ -111,6 +111,7 @@ class ReactorNetty2ServerHttpResponse extends AbstractServerHttpResponse impleme
for (ResponseCookie httpCookie : getCookies().get(name)) {
Long maxAge = (!httpCookie.getMaxAge().isNegative()) ? httpCookie.getMaxAge().getSeconds() : null;
HttpSetCookie.SameSite sameSite = (httpCookie.getSameSite() != null) ? HttpSetCookie.SameSite.valueOf(httpCookie.getSameSite()) : null;
// TODO: support Partitioned attribute when available in Netty 5 API
DefaultHttpSetCookie cookie = new DefaultHttpSetCookie(name, httpCookie.getValue(), httpCookie.getPath(),
httpCookie.getDomain(), null, maxAge, sameSite, false, httpCookie.isSecure(), httpCookie.isHttpOnly());
this.response.addCookie(cookie);

View File

@ -120,6 +120,7 @@ class ReactorServerHttpResponse extends AbstractServerHttpResponse implements Ze
}
cookie.setSecure(httpCookie.isSecure());
cookie.setHttpOnly(httpCookie.isHttpOnly());
cookie.setPartitioned(httpCookie.isPartitioned());
if (httpCookie.getSameSite() != null) {
cookie.setSameSite(CookieHeaderNames.SameSite.valueOf(httpCookie.getSameSite()));
}

View File

@ -39,6 +39,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
/**
* Adapt {@link ServerHttpResponse} to the Servlet {@link HttpServletResponse}.
@ -49,6 +50,8 @@ import org.springframework.util.Assert;
*/
class ServletServerHttpResponse extends AbstractListenerServerHttpResponse {
private static final boolean IS_SERVLET61 = ReflectionUtils.findField(HttpServletResponse.class, "SC_PERMANENT_REDIRECT") != null;
private final HttpServletResponse response;
private final ServletOutputStream outputStream;
@ -181,6 +184,14 @@ class ServletServerHttpResponse extends AbstractListenerServerHttpResponse {
}
cookie.setSecure(httpCookie.isSecure());
cookie.setHttpOnly(httpCookie.isHttpOnly());
if (httpCookie.isPartitioned()) {
if (IS_SERVLET61) {
cookie.setAttribute("Partitioned", "");
}
else {
cookie.setAttribute("Partitioned", "true");
}
}
this.response.addCookie(cookie);
}
}

View File

@ -122,6 +122,7 @@ class UndertowServerHttpResponse extends AbstractListenerServerHttpResponse impl
}
cookie.setSecure(httpCookie.isSecure());
cookie.setHttpOnly(httpCookie.isHttpOnly());
// TODO: add "Partitioned" attribute when Undertow supports it
cookie.setSameSiteMode(httpCookie.getSameSite());
this.exchange.setResponseCookie(cookie);
}

View File

@ -37,12 +37,12 @@ class ResponseCookieTests {
assertThat(ResponseCookie.from("id", "1fWa").build().toString()).isEqualTo("id=1fWa");
ResponseCookie cookie = ResponseCookie.from("id", "1fWa")
.domain("abc").path("/path").maxAge(0).httpOnly(true).secure(true).sameSite("None")
.domain("abc").path("/path").maxAge(0).httpOnly(true).partitioned(true).secure(true).sameSite("None")
.build();
assertThat(cookie.toString()).isEqualTo("id=1fWa; Path=/path; Domain=abc; " +
"Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " +
"Secure; HttpOnly; SameSite=None");
"Secure; HttpOnly; Partitioned; SameSite=None");
}
@Test

View File

@ -70,13 +70,32 @@ class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests {
List<String> cookie0 = splitCookie(headerValues.get(0));
assertThat(cookie0.remove("SID=31d4d96e407aad42")).as("SID").isTrue();
assertThat(cookie0.stream().map(String::toLowerCase))
.containsExactlyInAnyOrder("path=/", "secure", "httponly");
.contains("path=/", "secure", "httponly");
List<String> cookie1 = splitCookie(headerValues.get(1));
assertThat(cookie1.remove("lang=en-US")).as("lang").isTrue();
assertThat(cookie1.stream().map(String::toLowerCase))
.containsExactlyInAnyOrder("path=/", "domain=example.com");
}
@ParameterizedHttpServerTest
public void partitionedAttributeTest(HttpServer httpServer) throws Exception {
assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow does not support Partitioned cookies");
startServer(httpServer);
URI url = URI.create("http://localhost:" + port);
String header = "SID=31d4d96e407aad42; lang=en-US";
ResponseEntity<Void> response = new RestTemplate().exchange(
RequestEntity.get(url).header("Cookie", header).build(), Void.class);
List<String> headerValues = response.getHeaders().get("Set-Cookie");
assertThat(headerValues).hasSize(2);
List<String> cookie0 = splitCookie(headerValues.get(0));
assertThat(cookie0.remove("SID=31d4d96e407aad42")).as("SID").isTrue();
assertThat(cookie0.stream().map(String::toLowerCase))
.contains("partitioned");
}
@ParameterizedHttpServerTest
public void cookiesWithSameNameTest(HttpServer httpServer) throws Exception {
assumeFalse(httpServer instanceof UndertowHttpServer, "Bug in Undertow in Cookies with same name handling");
@ -116,7 +135,7 @@ class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests {
this.requestCookies.size(); // Cause lazy loading
response.getCookies().add("SID", ResponseCookie.from("SID", "31d4d96e407aad42")
.path("/").secure(true).httpOnly(true).build());
.path("/").secure(true).httpOnly(true).partitioned(true).build());
response.getCookies().add("lang", ResponseCookie.from("lang", "en-US")
.domain("example.com").path("/").build());

View File

@ -98,6 +98,24 @@ public class MockCookie extends Cookie {
return getAttribute(SAME_SITE);
}
/**
* Set the "Partitioned" attribute for this cookie.
* @since 6.2
* @see <a href="https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1">The Partitioned attribute spec</a>
*/
public void setPartitioned(boolean partitioned) {
setAttribute("Partitioned", "");
}
/**
* Return whether the "Partitioned" attribute is set for this cookie.
* @since 6.2
* @see <a href="https://datatracker.ietf.org/doc/html/draft-cutler-httpbis-partitioned-cookies#section-2.1">The Partitioned attribute spec</a>
*/
public boolean isPartitioned() {
return getAttribute("Partitioned") != null;
}
/**
* Factory method that parses the value of the supplied "Set-Cookie" header.
* @param setCookieHeader the "Set-Cookie" value; never {@code null} or empty
@ -146,6 +164,9 @@ public class MockCookie extends Cookie {
else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) {
cookie.setComment(extractAttributeValue(attribute, setCookieHeader));
}
else {
cookie.setAttribute(attribute, extractAttributeValue(attribute, setCookieHeader));
}
}
return cookie;
}

View File

@ -481,6 +481,9 @@ public class MockHttpServletResponse implements HttpServletResponse {
if (cookie.isHttpOnly()) {
buf.append("; HttpOnly");
}
if (cookie.getAttribute("Partitioned") != null) {
buf.append("; Partitioned");
}
if (cookie instanceof MockCookie mockCookie) {
if (StringUtils.hasText(mockCookie.getSameSite())) {
buf.append("; SameSite=").append(mockCookie.getSameSite());