Skip to content

Commit

Permalink
Add Partitioned cookie attribute support for servers
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bclozel committed Jun 7, 2024
1 parent 2aabe23 commit 7fc4937
Show file tree
Hide file tree
Showing 18 changed files with 178 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ public String getSameSite() {
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
Expand Down Expand Up @@ -146,6 +164,9 @@ else if (StringUtils.startsWithIgnoreCase(attribute, SAME_SITE)) {
else if (StringUtils.startsWithIgnoreCase(attribute, "Comment")) {
cookie.setComment(extractAttributeValue(attribute, setCookieHeader));
}
else if (!attribute.isEmpty()) {
cookie.setAttribute(attribute, extractOptionalAttributeValue(attribute, setCookieHeader));
}
}
return cookie;
}
Expand All @@ -157,6 +178,11 @@ private static String extractAttributeValue(String attribute, String header) {
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)) {
Expand All @@ -176,6 +202,7 @@ public String toString() {
.append("Comment", getComment())
.append("Secure", getSecure())
.append("HttpOnly", isHttpOnly())
.append("Partitioned", isPartitioned())
.append(SAME_SITE, getSameSite())
.append("Max-Age", getMaxAge())
.append(EXPIRES, getAttribute(EXPIRES))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,9 @@ else if (expires != null) {
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ public WebTestClient.ResponseSpec httpOnly(String name, boolean expected) {
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ private MockClientHttpResponse adaptResponse(MvcResult mvcResult) {
.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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -229,6 +229,17 @@ public ResultMatcher httpOnly(String name, boolean httpOnly) {
};
}

/**
* 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,15 @@ void parseHeaderWithoutAttributes() {
@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");
assertThat(cookie.getMaxAge()).isEqualTo(60);
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");
Expand Down Expand Up @@ -203,4 +204,12 @@ void setInvalidAttributeExpiresShouldThrow() {
assertThatThrownBy(() -> cookie.setAttribute("expires", "12345")).isInstanceOf(DateTimeParseException.class);
}

@Test
void setPartitioned() {
MockCookie cookie = new MockCookie("SESSION", "123");
cookie.setAttribute("Partitioned", "");

assertThat(cookie.isPartitioned()).isTrue();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,13 @@ void cookies() {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@
*
* @author Rossen Stoyanchev
*/
public class CookieAssertionTests {
public class CookieAssertionsTests {

private final ResponseCookie cookie = ResponseCookie.from("foo", "bar")
.maxAge(Duration.ofMinutes(30))
.domain("foo.com")
.path("/foo")
.secure(true)
.httpOnly(true)
.partitioned(true)
.sameSite("Lax")
.build();

Expand Down Expand Up @@ -117,6 +118,12 @@ void httpOnly() {
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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public final class ResponseCookie extends HttpCookie {

private final boolean httpOnly;

private final boolean partitioned;

@Nullable
private final String sameSite;

Expand All @@ -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");
Expand All @@ -65,6 +67,7 @@ private ResponseCookie(String name, @Nullable String value, Duration maxAge, @Nu
this.path = path;
this.secure = secure;
this.httpOnly = httpOnly;
this.partitioned = partitioned;
this.sameSite = sameSite;

Rfc6265Utils.validateCookieName(name);
Expand Down Expand Up @@ -116,6 +119,15 @@ public boolean isHttpOnly() {
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
Expand All @@ -139,6 +151,7 @@ public ResponseCookieBuilder mutate() {
.path(this.path)
.secure(this.secure)
.httpOnly(this.httpOnly)
.partitioned(this.partitioned)
.sameSite(this.sameSite);
}

Expand Down Expand Up @@ -180,6 +193,9 @@ public String toString() {
if (this.httpOnly) {
sb.append("; HttpOnly");
}
if (this.partitioned) {
sb.append("; Partitioned");
}
if (StringUtils.hasText(this.sameSite)) {
sb.append("; SameSite=").append(this.sameSite);
}
Expand Down Expand Up @@ -272,6 +288,13 @@ public interface ResponseCookieBuilder {
*/
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
Expand Down Expand Up @@ -397,6 +420,8 @@ private static class DefaultResponseCookieBuilder implements ResponseCookieBuild

private boolean httpOnly;

private boolean partitioned;

@Nullable
private String sameSite;

Expand Down Expand Up @@ -461,6 +486,12 @@ public ResponseCookieBuilder httpOnly(boolean httpOnly) {
return this;
}

@Override
public ResponseCookieBuilder partitioned(boolean partitioned) {
this.partitioned = partitioned;
return this;
}

@Override
public ResponseCookieBuilder sameSite(@Nullable String sameSite) {
this.sameSite = sameSite;
Expand All @@ -470,7 +501,7 @@ public ResponseCookieBuilder sameSite(@Nullable String sameSite) {
@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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ protected void applyCookies() {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ protected void applyCookies() {
}
cookie.setSecure(httpCookie.isSecure());
cookie.setHttpOnly(httpCookie.isHttpOnly());
cookie.setPartitioned(httpCookie.isPartitioned());
if (httpCookie.getSameSite() != null) {
cookie.setSameSite(CookieHeaderNames.SameSite.valueOf(httpCookie.getSameSite()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
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}.
Expand All @@ -49,6 +50,8 @@
*/
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;
Expand Down Expand Up @@ -181,6 +184,14 @@ protected void applyCookies() {
}
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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ protected void applyCookies() {
}
cookie.setSecure(httpCookie.isSecure());
cookie.setHttpOnly(httpCookie.isHttpOnly());
// TODO: add "Partitioned" attribute when Undertow supports it
cookie.setSameSiteMode(httpCookie.getSameSite());
this.exchange.setResponseCookie(cookie);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ 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).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
Expand Down
Loading

0 comments on commit 7fc4937

Please sign in to comment.