Add SameSite cookie support for servlet web servers

Update Tomcat, Jetty and Undertow `ServletWebServerFactory`
implementations so that they can write SameSite cookie attributes.

The session cookie will be customized whenever the
`server.servlet.session.cookie.same-site` property is set.

Other cookies can be customized with the new `CookieSameSiteSupplier`
interface which can be registered using `@Bean` methods.

Closes gh-20971

Co-authored-by Andy Wilkinson <wilkinsona@vmware.com>
This commit is contained in:
Phillip Webb 2021-10-20 22:13:06 -07:00
parent efa04367b1
commit cf9156e497
13 changed files with 781 additions and 13 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 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.
@ -41,6 +41,7 @@ import org.springframework.boot.web.server.ErrorPageRegistrarBeanPostProcessor;
import org.springframework.boot.web.server.WebServerFactoryCustomizerBeanPostProcessor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.WebListenerRegistrar;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@ -73,9 +74,11 @@ public class ServletWebServerFactoryAutoConfiguration {
@Bean
public ServletWebServerFactoryCustomizer servletWebServerFactoryCustomizer(ServerProperties serverProperties,
ObjectProvider<WebListenerRegistrar> webListenerRegistrars) {
ObjectProvider<WebListenerRegistrar> webListenerRegistrars,
ObjectProvider<CookieSameSiteSupplier> cookieSameSiteSuppliers) {
return new ServletWebServerFactoryCustomizer(serverProperties,
webListenerRegistrars.orderedStream().collect(Collectors.toList()));
webListenerRegistrars.orderedStream().collect(Collectors.toList()),
cookieSameSiteSuppliers.orderedStream().collect(Collectors.toList()));
}
@Bean

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 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.
@ -24,11 +24,13 @@ import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.WebListenerRegistrar;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.core.Ordered;
import org.springframework.util.CollectionUtils;
/**
* {@link WebServerFactoryCustomizer} to apply {@link ServerProperties} to servlet web
* servers.
* {@link WebServerFactoryCustomizer} to apply {@link ServerProperties} and
* {@link WebListenerRegistrar WebListenerRegistrars} to servlet web servers.
*
* @author Brian Clozel
* @author Stephane Nicoll
@ -41,7 +43,9 @@ public class ServletWebServerFactoryCustomizer
private final ServerProperties serverProperties;
private final Iterable<WebListenerRegistrar> webListenerRegistrars;
private final List<WebListenerRegistrar> webListenerRegistrars;
private final List<CookieSameSiteSupplier> cookieSameSiteSuppliers;
public ServletWebServerFactoryCustomizer(ServerProperties serverProperties) {
this(serverProperties, Collections.emptyList());
@ -49,8 +53,14 @@ public class ServletWebServerFactoryCustomizer
public ServletWebServerFactoryCustomizer(ServerProperties serverProperties,
List<WebListenerRegistrar> webListenerRegistrars) {
this(serverProperties, webListenerRegistrars, null);
}
ServletWebServerFactoryCustomizer(ServerProperties serverProperties,
List<WebListenerRegistrar> webListenerRegistrars, List<CookieSameSiteSupplier> cookieSameSiteSuppliers) {
this.serverProperties = serverProperties;
this.webListenerRegistrars = webListenerRegistrars;
this.cookieSameSiteSuppliers = cookieSameSiteSuppliers;
}
@Override
@ -77,6 +87,9 @@ public class ServletWebServerFactoryCustomizer
for (WebListenerRegistrar registrar : this.webListenerRegistrars) {
registrar.register(factory);
}
if (!CollectionUtils.isEmpty(this.cookieSameSiteSuppliers)) {
factory.setCookieSameSiteSuppliers(this.cookieSameSiteSuppliers);
}
}
}

View File

@ -51,7 +51,9 @@ import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
@ -369,6 +371,14 @@ class ServletWebServerFactoryAutoConfigurationTests {
.run((context) -> assertThat(context).hasSingleBean(FilterRegistrationBean.class));
}
@Test
void cookieSameSiteSuppliersAreApplied() {
this.contextRunner.withUserConfiguration(CookieSameSiteSupplierConfiguration.class).run((context) -> {
AbstractServletWebServerFactory webServerFactory = context.getBean(AbstractServletWebServerFactory.class);
assertThat(webServerFactory.getCookieSameSiteSuppliers()).hasSize(2);
});
}
private ContextConsumer<AssertableWebApplicationContext> verifyContext() {
return this::verifyContext;
}
@ -656,4 +666,19 @@ class ServletWebServerFactoryAutoConfigurationTests {
}
@Configuration(proxyBeanMethods = false)
static class CookieSameSiteSupplierConfiguration {
@Bean
CookieSameSiteSupplier cookieSameSiteSupplier1() {
return CookieSameSiteSupplier.ofLax().whenHasName("test1");
}
@Bean
CookieSameSiteSupplier cookieSameSiteSupplier2() {
return CookieSameSiteSupplier.ofNone().whenHasName("test2");
}
}
}

View File

@ -588,6 +588,39 @@ TIP: See the {spring-boot-autoconfigure-module-code}/web/ServerProperties.java[`
[[web.servlet.embedded-container.customizing.samesite]]
===== SameSite Cookies
The `SameSite` cookie attribute can be used by web browsers to control if and how cookies are submitted in cross-site requests.
The attribute is particularly relevant for modern web browsers which have started to change the default value that is used when the attribute is missing.
If you want to change the `SameSite` attribute of your session cookie, you can use the configprop:server.servlet.session.cookie.same-site[] property.
This property is supported by auto-configured Tomcat, Jetty and Undertow servers.
It is also used to configure Spring Session servlet based `SessionRepository` beans.
For example, if you want your session cookie to have a `SameSite` attribute of `None`, you can add the following to you `application.properties` or `application.yaml` file:
[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
----
server:
servlet:
session:
cookie:
same-site: "none"
----
If you want to change the `SameSite` attribute on other cookies added to your `HttpServletResponse`, you can use a `CookieSameSiteSupplier`.
The `CookieSameSiteSupplier` is passed a `Cookie` and may return a `SameSite` value, or `null`.
There are a number of convenience factory and filter methods that you can use to quickly match specific cookies.
For example, adding the following bean will automatically apply a `SameSite` of `Lax` for all cookies with a name that matches the regular expression `myapp.*`.
[source,java,indent=0,subs="verbatim"]
----
include::{docs-java}/web/servlet/embeddedcontainer/customizing/samesite/MySameSiteConfiguration.java[]
----
[[web.servlet.embedded-container.customizing.programmatic]]
===== Programmatic Customization
If you need to programmatically configure your embedded servlet container, you can register a Spring bean that implements the `WebServerFactoryCustomizer` interface.

View File

@ -0,0 +1,31 @@
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.docs.web.servlet.embeddedcontainer.customizing.samesite;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class MySameSiteConfiguration {
@Bean
public CookieSameSiteSupplier applicationCookieSameSiteSupplier() {
return CookieSameSiteSupplier.ofLax().whenHasNameMatching("myapp.*");
}
}

View File

@ -33,6 +33,13 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import org.eclipse.jetty.http.HttpCookie;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.server.AbstractConnector;
@ -41,6 +48,7 @@ import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.ErrorHandler;
@ -63,16 +71,19 @@ import org.eclipse.jetty.webapp.AbstractConfiguration;
import org.eclipse.jetty.webapp.Configuration;
import org.eclipse.jetty.webapp.WebAppContext;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.MimeMappings;
import org.springframework.boot.web.server.Shutdown;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
@ -199,6 +210,9 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor
if (StringUtils.hasText(getServerHeader())) {
handler = applyWrapper(handler, JettyHandlerWrappers.createServerHeaderHandlerWrapper(getServerHeader()));
}
if (!CollectionUtils.isEmpty(getCookieSameSiteSuppliers())) {
handler = applyWrapper(handler, new SuppliedSameSiteCookieHandlerWrapper(getCookieSameSiteSuppliers()));
}
return handler;
}
@ -245,6 +259,10 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor
private void configureSession(WebAppContext context) {
SessionHandler handler = context.getSessionHandler();
SameSite sessionSameSite = getSession().getCookie().getSameSite();
if (sessionSameSite != null) {
handler.setSameSite(HttpCookie.SameSite.valueOf(sessionSameSite.name()));
}
Duration sessionTimeout = getSession().getTimeout();
handler.setMaxInactiveInterval(isNegative(sessionTimeout) ? -1 : (int) sessionTimeout.getSeconds());
if (getSession().isPersistent()) {
@ -661,4 +679,66 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor
}
/**
* {@link HandlerWrapper} to apply {@link CookieSameSiteSupplier supplied}
* {@link SameSite} cookie values.
*/
private static class SuppliedSameSiteCookieHandlerWrapper extends HandlerWrapper {
private final List<CookieSameSiteSupplier> suppliers;
SuppliedSameSiteCookieHandlerWrapper(List<CookieSameSiteSupplier> suppliers) {
this.suppliers = suppliers;
}
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
HttpServletResponse wrappedResponse = new ResposeWrapper(response);
super.handle(target, baseRequest, request, wrappedResponse);
}
class ResposeWrapper extends HttpServletResponseWrapper {
ResposeWrapper(HttpServletResponse response) {
super(response);
}
@Override
public void addCookie(Cookie cookie) {
SameSite sameSite = getSameSite(cookie);
if (sameSite != null) {
String comment = HttpCookie.getCommentWithoutAttributes(cookie.getComment());
String sameSiteComment = getSameSiteComment(sameSite);
cookie.setComment((comment != null) ? comment + sameSiteComment : sameSiteComment);
}
super.addCookie(cookie);
}
private String getSameSiteComment(SameSite sameSite) {
switch (sameSite) {
case NONE:
return HttpCookie.SAME_SITE_NONE_COMMENT;
case LAX:
return HttpCookie.SAME_SITE_LAX_COMMENT;
case STRICT:
return HttpCookie.SAME_SITE_STRICT_COMMENT;
}
throw new IllegalStateException("Unsupported SameSite value " + sameSite);
}
private SameSite getSameSite(Cookie cookie) {
for (CookieSameSiteSupplier supplier : SuppliedSameSiteCookieHandlerWrapper.this.suppliers) {
SameSite sameSite = supplier.getSameSite(cookie);
if (sameSite != null) {
return sameSite;
}
}
return null;
}
}
}
}

View File

@ -34,6 +34,8 @@ import java.util.Set;
import java.util.stream.Collectors;
import javax.servlet.ServletContainerInitializer;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import org.apache.catalina.Context;
import org.apache.catalina.Engine;
@ -55,26 +57,31 @@ import org.apache.catalina.session.StandardManager;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.startup.Tomcat.FixContextListener;
import org.apache.catalina.util.LifecycleBase;
import org.apache.catalina.util.SessionConfig;
import org.apache.catalina.webresources.AbstractResourceSet;
import org.apache.catalina.webresources.EmptyResource;
import org.apache.catalina.webresources.StandardRoot;
import org.apache.coyote.AbstractProtocol;
import org.apache.coyote.ProtocolHandler;
import org.apache.coyote.http2.Http2Protocol;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.modeler.Registry;
import org.apache.tomcat.util.scan.StandardJarScanFilter;
import org.springframework.boot.util.LambdaSafe;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.MimeMappings;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.NativeDetector;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
@ -381,6 +388,7 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto
context.addMimeMapping(mapping.getExtension(), mapping.getMimeType());
}
configureSession(context);
configureCookieProcessor(context);
new DisableReferenceClearingContextCustomizer().customize(context);
for (String webListenerClassName : getWebListenerClassNames()) {
context.addApplicationListener(webListenerClassName);
@ -410,6 +418,21 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto
}
}
private void configureCookieProcessor(Context context) {
SameSite sessionSameSite = getSession().getCookie().getSameSite();
List<CookieSameSiteSupplier> suppliers = new ArrayList<>();
if (sessionSameSite != null) {
suppliers.add(CookieSameSiteSupplier.of(sessionSameSite)
.whenHasName(() -> SessionConfig.getSessionCookieName(context)));
}
if (!CollectionUtils.isEmpty(getCookieSameSiteSuppliers())) {
suppliers.addAll(getCookieSameSiteSuppliers());
}
if (!suppliers.isEmpty()) {
context.setCookieProcessor(new SuppliedSameSiteCookieProcessor(suppliers));
}
}
private void configurePersistSession(Manager manager) {
Assert.state(manager instanceof StandardManager,
() -> "Unable to persist HTTP session state using manager type " + manager.getClass().getName());
@ -880,4 +903,39 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto
}
/**
* {@link Rfc6265CookieProcessor} that supports {@link CookieSameSiteSupplier
* supplied} {@link SameSite} values.
*/
private static class SuppliedSameSiteCookieProcessor extends Rfc6265CookieProcessor {
private final List<CookieSameSiteSupplier> suppliers;
SuppliedSameSiteCookieProcessor(List<CookieSameSiteSupplier> suppliers) {
this.suppliers = suppliers;
}
@Override
public String generateHeader(Cookie cookie, HttpServletRequest request) {
SameSite sameSite = getSameSite(cookie);
if (sameSite == null) {
return super.generateHeader(cookie, request);
}
Rfc6265CookieProcessor delegate = new Rfc6265CookieProcessor();
delegate.setSameSiteCookies(sameSite.attributeValue());
return delegate.generateHeader(cookie, request);
}
private SameSite getSameSite(Cookie cookie) {
for (CookieSameSiteSupplier supplier : this.suppliers) {
SameSite sameSite = supplier.getSameSite(cookie);
if (sameSite != null) {
return sameSite;
}
}
return null;
}
}
}

View File

@ -39,6 +39,9 @@ import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import io.undertow.Undertow.Builder;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.Cookie;
import io.undertow.server.handlers.resource.FileResourceManager;
import io.undertow.server.handlers.resource.Resource;
import io.undertow.server.handlers.resource.ResourceChangeListener;
@ -46,6 +49,7 @@ import io.undertow.server.handlers.resource.ResourceManager;
import io.undertow.server.handlers.resource.URLResource;
import io.undertow.server.session.SessionManager;
import io.undertow.servlet.Servlets;
import io.undertow.servlet.api.Deployment;
import io.undertow.servlet.api.DeploymentInfo;
import io.undertow.servlet.api.DeploymentManager;
import io.undertow.servlet.api.ListenerInfo;
@ -56,15 +60,19 @@ import io.undertow.servlet.core.DeploymentImpl;
import io.undertow.servlet.handlers.DefaultServlet;
import io.undertow.servlet.util.ImmediateInstanceFactory;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.MimeMappings.Mapping;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
/**
* {@link ServletWebServerFactory} that can be used to create
@ -415,9 +423,9 @@ public class UndertowServletWebServerFactory extends AbstractServletWebServerFac
}
}
private void configureErrorPages(DeploymentInfo servletBuilder) {
private void configureErrorPages(DeploymentInfo deployment) {
for (ErrorPage errorPage : getErrorPages()) {
servletBuilder.addErrorPage(getUndertowErrorPage(errorPage));
deployment.addErrorPage(getUndertowErrorPage(errorPage));
}
}
@ -431,9 +439,9 @@ public class UndertowServletWebServerFactory extends AbstractServletWebServerFac
return new io.undertow.servlet.api.ErrorPage(errorPage.getPath());
}
private void configureMimeMappings(DeploymentInfo servletBuilder) {
private void configureMimeMappings(DeploymentInfo deployment) {
for (Mapping mimeMapping : getMimeMappings()) {
servletBuilder.addMimeMapping(new MimeMapping(mimeMapping.getExtension(), mimeMapping.getMimeType()));
deployment.addMimeMapping(new MimeMapping(mimeMapping.getExtension(), mimeMapping.getMimeType()));
}
}
@ -458,11 +466,30 @@ public class UndertowServletWebServerFactory extends AbstractServletWebServerFac
* @return a new {@link UndertowServletWebServer} instance
*/
protected UndertowServletWebServer getUndertowWebServer(Builder builder, DeploymentManager manager, int port) {
List<HttpHandlerFactory> initialHandlerFactories = new ArrayList<>();
initialHandlerFactories.add(new DeploymentManagerHttpHandlerFactory(manager));
HttpHandlerFactory cooHandlerFactory = getCookieHandlerFactory(manager.getDeployment());
if (cooHandlerFactory != null) {
initialHandlerFactories.add(cooHandlerFactory);
}
List<HttpHandlerFactory> httpHandlerFactories = this.delegate.createHttpHandlerFactories(this,
new DeploymentManagerHttpHandlerFactory(manager));
initialHandlerFactories.toArray(new HttpHandlerFactory[0]));
return new UndertowServletWebServer(builder, httpHandlerFactories, getContextPath(), port >= 0);
}
private HttpHandlerFactory getCookieHandlerFactory(Deployment deployment) {
SameSite sessionSameSite = getSession().getCookie().getSameSite();
List<CookieSameSiteSupplier> suppliers = new ArrayList<>();
if (sessionSameSite != null) {
String sessionCookieName = deployment.getServletContext().getSessionCookieConfig().getName();
suppliers.add(CookieSameSiteSupplier.of(sessionSameSite).whenHasName(sessionCookieName));
}
if (!CollectionUtils.isEmpty(getCookieSameSiteSuppliers())) {
suppliers.addAll(getCookieSameSiteSuppliers());
}
return (!suppliers.isEmpty()) ? (next) -> new SuppliedSameSiteCookieHandler(next, suppliers) : null;
}
/**
* {@link ServletContainerInitializer} to initialize {@link ServletContextInitializer
* ServletContextInitializers}.
@ -583,4 +610,59 @@ public class UndertowServletWebServerFactory extends AbstractServletWebServerFac
}
/**
* {@link HttpHandler} to apply {@link CookieSameSiteSupplier supplied}
* {@link SameSite} cookie values.
*/
private static class SuppliedSameSiteCookieHandler implements HttpHandler {
private final HttpHandler next;
private final List<CookieSameSiteSupplier> suppliers;
SuppliedSameSiteCookieHandler(HttpHandler next, List<CookieSameSiteSupplier> suppliers) {
this.next = next;
this.suppliers = suppliers;
}
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
exchange.addResponseCommitListener(this::beforeCommit);
this.next.handleRequest(exchange);
}
private void beforeCommit(HttpServerExchange exchange) {
for (Cookie cookie : exchange.responseCookies()) {
SameSite sameSite = getSameSite(asServletCookie(cookie));
if (sameSite != null) {
cookie.setSameSiteMode(sameSite.attributeValue());
}
}
}
private javax.servlet.http.Cookie asServletCookie(Cookie cookie) {
PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
javax.servlet.http.Cookie result = new javax.servlet.http.Cookie(cookie.getName(), cookie.getValue());
map.from(cookie::getComment).to(result::setComment);
map.from(cookie::getDomain).to(result::setDomain);
map.from(cookie::getMaxAge).to(result::setMaxAge);
map.from(cookie::getPath).to(result::setPath);
result.setSecure(cookie.isSecure());
result.setVersion(cookie.getVersion());
result.setHttpOnly(cookie.isHttpOnly());
return result;
}
private SameSite getSameSite(javax.servlet.http.Cookie cookie) {
for (CookieSameSiteSupplier supplier : this.suppliers) {
SameSite sameSite = supplier.getSameSite(cookie);
if (sameSite != null) {
return sameSite;
}
}
return null;
}
}
}

View File

@ -80,6 +80,8 @@ public abstract class AbstractServletWebServerFactory extends AbstractConfigurab
private Map<String, String> initParameters = Collections.emptyMap();
private List<CookieSameSiteSupplier> cookieSameSiteSuppliers = new ArrayList<>();
private final DocumentRoot documentRoot = new DocumentRoot(this.logger);
private final StaticResourceJars staticResourceJars = new StaticResourceJars();
@ -242,6 +244,22 @@ public abstract class AbstractServletWebServerFactory extends AbstractConfigurab
return this.initParameters;
}
@Override
public void setCookieSameSiteSuppliers(List<? extends CookieSameSiteSupplier> cookieSameSiteSuppliers) {
Assert.notNull(cookieSameSiteSuppliers, "CookieSameSiteSuppliers must not be null");
this.cookieSameSiteSuppliers = new ArrayList<>(cookieSameSiteSuppliers);
}
@Override
public void addCookieSameSiteSuppliers(CookieSameSiteSupplier... cookieSameSiteSuppliers) {
Assert.notNull(cookieSameSiteSuppliers, "CookieSameSiteSuppliers must not be null");
this.cookieSameSiteSuppliers.addAll(Arrays.asList(cookieSameSiteSuppliers));
}
public List<CookieSameSiteSupplier> getCookieSameSiteSuppliers() {
return this.cookieSameSiteSuppliers;
}
/**
* Utility method that can be used by subclasses wishing to combine the specified
* {@link ServletContextInitializer} parameters with those defined in this instance.

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 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.
@ -25,6 +25,7 @@ import java.util.Map;
import javax.servlet.ServletContext;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.boot.web.server.MimeMappings;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
@ -125,4 +126,21 @@ public interface ConfigurableServletWebServerFactory
*/
void setInitParameters(Map<String, String> initParameters);
/**
* Sets {@link CookieSameSiteSupplier CookieSameSiteSuppliers} that should be used to
* obtain the {@link SameSite} attribute of any added cookie. This method will replace
* any previously set or added suppliers.
* @param cookieSameSiteSuppliers the suppliers to add
* @see #addCookieSameSiteSuppliers
*/
void setCookieSameSiteSuppliers(List<? extends CookieSameSiteSupplier> cookieSameSiteSuppliers);
/**
* Add {@link CookieSameSiteSupplier CookieSameSiteSuppliers} to those that should be
* used to obtain the {@link SameSite} attribute of any added cookie.
* @param cookieSameSiteSuppliers the suppliers to add
* @see #setCookieSameSiteSuppliers
*/
void addCookieSameSiteSuppliers(CookieSameSiteSupplier... cookieSameSiteSuppliers);
}

View File

@ -0,0 +1,152 @@
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.web.servlet.server;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.regex.Pattern;
/**
* @author Phillip Webb
*/
import javax.servlet.http.Cookie;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Strategy interface that can be used with {@link ConfigurableServletWebServerFactory}
* implementations in order to supply custom {@link SameSite} values for specific
* {@link Cookie cookies}.
* <p>
* Basic CookieSameSiteSupplier implementations can be constructed using the {@code of...}
* factory methods, typically combined with name matching. For example: <pre class="code">
* CookieSameSiteSupplier.ofLax().whenHasName("mycookie");
* </pre>
*
* @author Phillip Webb
* @since 2.6.0
* @see ConfigurableServletWebServerFactory#addCookieSameSiteSuppliers(CookieSameSiteSupplier...)
*/
@FunctionalInterface
public interface CookieSameSiteSupplier {
/**
* Get the {@link SameSite} values that should be used for the given {@link Cookie}.
* @param cookie the cookie to check
* @return the {@link SameSite} value to use or {@code null} if the next supplier
* should be checked
*/
SameSite getSameSite(Cookie cookie);
/**
* Limit this supplier so that it's only called if the Cookie has the given name.
* @param name the name to check
* @return a new {@link CookieSameSiteSupplier} that only calls this supplier when the
* name matches
*/
default CookieSameSiteSupplier whenHasName(String name) {
Assert.hasText(name, "Name must not be empty");
return when((cookie) -> ObjectUtils.nullSafeEquals(cookie.getName(), name));
}
/**
* Limit this supplier so that it's only called if the Cookie has the given name.
* @param nameSupplier a supplier providing the name to check
* @return a new {@link CookieSameSiteSupplier} that only calls this supplier when the
* name matches
*/
default CookieSameSiteSupplier whenHasName(Supplier<String> nameSupplier) {
Assert.notNull(nameSupplier, "NameSupplier must not be empty");
return when((cookie) -> ObjectUtils.nullSafeEquals(cookie.getName(), nameSupplier.get()));
}
/**
* Limit this supplier so that it's only called if the Cookie name matches the given
* regex.
* @param regex the regex pattern that must match
* @return a new {@link CookieSameSiteSupplier} that only calls this supplier when the
* name matches the regex
*/
default CookieSameSiteSupplier whenHasNameMatching(String regex) {
Assert.hasText(regex, "Regex must not be empty");
return whenHasNameMatching(Pattern.compile(regex));
}
/**
* Limit this supplier so that it's only called if the Cookie name matches the given
* {@link Pattern}.
* @param pattern the regex pattern that must match
* @return a new {@link CookieSameSiteSupplier} that only calls this supplier when the
* name matches the pattern
*/
default CookieSameSiteSupplier whenHasNameMatching(Pattern pattern) {
Assert.notNull(pattern, "Pattern must not be null");
return when((cookie) -> pattern.matcher(cookie.getName()).matches());
}
/**
* Limit this supplier so that it's only called if the predicate accepts the Cookie.
* @param predicate the predicate used to match the cookie
* @return a new {@link CookieSameSiteSupplier} that only calls this supplier when the
* cookie matches the predicate
*/
default CookieSameSiteSupplier when(Predicate<Cookie> predicate) {
Assert.notNull(predicate, "Predicate must not be null");
return (cookie) -> predicate.test(cookie) ? getSameSite(cookie) : null;
}
/**
* Return a new {@link CookieSameSiteSupplier} that always returns
* {@link SameSite#NONE}.
* @return the supplier instance
*/
static CookieSameSiteSupplier ofNone() {
return of(SameSite.NONE);
}
/**
* Return a new {@link CookieSameSiteSupplier} that always returns
* {@link SameSite#LAX}.
* @return the supplier instance
*/
static CookieSameSiteSupplier ofLax() {
return of(SameSite.LAX);
}
/**
* Return a new {@link CookieSameSiteSupplier} that always returns
* {@link SameSite#STRICT}.
* @return the supplier instance
*/
static CookieSameSiteSupplier ofStrict() {
return of(SameSite.STRICT);
}
/**
* Return a new {@link CookieSameSiteSupplier} that always returns the given
* {@link SameSite} value.
* @param sameSite the value to return
* @return the supplier instance
*/
static CookieSameSiteSupplier of(SameSite sameSite) {
Assert.notNull(sameSite, "SameSite must not be null");
return (cookie) -> sameSite;
}
}

View File

@ -42,6 +42,7 @@ import java.util.Collections;
import java.util.Date;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@ -73,6 +74,7 @@ import javax.servlet.ServletRegistration.Dynamic;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.SessionCookieConfig;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@ -105,6 +107,8 @@ import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.InOrder;
import org.springframework.boot.system.ApplicationHome;
@ -114,6 +118,7 @@ import org.springframework.boot.testsupport.system.OutputCaptureExtension;
import org.springframework.boot.testsupport.web.servlet.ExampleFilter;
import org.springframework.boot.testsupport.web.servlet.ExampleServlet;
import org.springframework.boot.web.server.Compression;
import org.springframework.boot.web.server.Cookie.SameSite;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.GracefulShutdownResult;
import org.springframework.boot.web.server.Http2;
@ -810,6 +815,57 @@ public abstract class AbstractServletWebServerFactoryTests {
assertThat(sessionCookieConfig.getMaxAge()).isEqualTo(60);
}
@ParameterizedTest
@EnumSource(SameSite.class)
void sessionCookieSameSiteAttributeCanBeConfiguredAndOnlyAffectsSessionCookies(SameSite sameSite) throws Exception {
AbstractServletWebServerFactory factory = getFactory();
factory.getSession().getCookie().setSameSite(sameSite);
factory.addInitializers(new ServletRegistrationBean<>(new CookieServlet(false), "/"));
this.webServer = factory.getWebServer();
this.webServer.start();
ClientHttpResponse clientResponse = getClientResponse(getLocalUrl("/"));
List<String> setCookieHeaders = clientResponse.getHeaders().get("Set-Cookie");
assertThat(setCookieHeaders).satisfiesExactlyInAnyOrder(
(header) -> assertThat(header).contains("JSESSIONID").contains("SameSite=" + sameSite.attributeValue()),
(header) -> assertThat(header).contains("test=test").doesNotContain("SameSite"));
}
@ParameterizedTest
@EnumSource(SameSite.class)
void sessionCookieSameSiteAttributeCanBeConfiguredAndOnlyAffectsSessionCookiesWhenUsingCustomName(SameSite sameSite)
throws Exception {
AbstractServletWebServerFactory factory = getFactory();
factory.getSession().getCookie().setName("THESESSION");
factory.getSession().getCookie().setSameSite(sameSite);
factory.addInitializers(new ServletRegistrationBean<>(new CookieServlet(false), "/"));
this.webServer = factory.getWebServer();
this.webServer.start();
ClientHttpResponse clientResponse = getClientResponse(getLocalUrl("/"));
List<String> setCookieHeaders = clientResponse.getHeaders().get("Set-Cookie");
assertThat(setCookieHeaders).satisfiesExactlyInAnyOrder(
(header) -> assertThat(header).contains("THESESSION").contains("SameSite=" + sameSite.attributeValue()),
(header) -> assertThat(header).contains("test=test").doesNotContain("SameSite"));
}
@Test
void cookieSameSiteSuppliers() throws Exception {
AbstractServletWebServerFactory factory = getFactory();
factory.addCookieSameSiteSuppliers(CookieSameSiteSupplier.ofLax().whenHasName("relaxed"));
factory.addCookieSameSiteSuppliers(CookieSameSiteSupplier.ofNone().whenHasName("empty"));
factory.addCookieSameSiteSuppliers(CookieSameSiteSupplier.ofStrict().whenHasName("controlled"));
factory.addInitializers(new ServletRegistrationBean<>(new CookieServlet(true), "/"));
this.webServer = factory.getWebServer();
this.webServer.start();
ClientHttpResponse clientResponse = getClientResponse(getLocalUrl("/"));
List<String> setCookieHeaders = clientResponse.getHeaders().get("Set-Cookie");
assertThat(setCookieHeaders).satisfiesExactlyInAnyOrder(
(header) -> assertThat(header).contains("JSESSIONID").doesNotContain("SameSite"),
(header) -> assertThat(header).contains("test=test").doesNotContain("SameSite"),
(header) -> assertThat(header).contains("relaxed=test").contains("SameSite=Lax"),
(header) -> assertThat(header).contains("empty=test").contains("SameSite=None"),
(header) -> assertThat(header).contains("controlled=test").contains("SameSite=Strict"));
}
@Test
void sslSessionTracking() {
AbstractServletWebServerFactory factory = getFactory();
@ -1611,4 +1667,25 @@ public abstract class AbstractServletWebServerFactoryTests {
}
static final class CookieServlet extends HttpServlet {
private final boolean addSupplierTestCookies;
CookieServlet(boolean addSupplierTestCookies) {
this.addSupplierTestCookies = addSupplierTestCookies;
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getSession(true);
resp.addCookie(new Cookie("test", "test"));
if (this.addSupplierTestCookies) {
resp.addCookie(new Cookie("relaxed", "test"));
resp.addCookie(new Cookie("empty", "test"));
resp.addCookie(new Cookie("controlled", "test"));
}
}
}
}

View File

@ -0,0 +1,178 @@
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.web.servlet.server;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import javax.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.server.Cookie.SameSite;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.fail;
/**
* Tests for {@link CookieSameSiteSupplier}.
*
* @author Phillip Webb
*/
class CookieSameSiteSupplierTests {
@Test
void whenHasNameWhenNameIsNullThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.whenHasName((String) null))
.withMessage("Name must not be empty");
}
@Test
void whenHasNameWhenNameIsEmptyThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.whenHasName(""))
.withMessage("Name must not be empty");
}
@Test
void whenHasNameWhenNameMatchesCallsGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThat(supplier.whenHasName("test").getSameSite(new Cookie("test", "x"))).isEqualTo(SameSite.LAX);
}
@Test
void whenHasNameWhenNameDoesNotMatchDoesNotCallGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> fail("Supplier Called");
assertThat(supplier.whenHasName("test").getSameSite(new Cookie("tset", "x"))).isNull();
}
@Test
void whenHasSuppliedNameWhenNameIsNullThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.whenHasName((Supplier<String>) null))
.withMessage("NameSupplier must not be empty");
}
@Test
void whenHasSuppliedNameWhenNameMatchesCallsGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThat(supplier.whenHasName(() -> "test").getSameSite(new Cookie("test", "x"))).isEqualTo(SameSite.LAX);
}
@Test
void whenHasSuppliedNameWhenNameDoesNotMatchDoesNotCallGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> fail("Supplier Called");
assertThat(supplier.whenHasName(() -> "test").getSameSite(new Cookie("tset", "x"))).isNull();
}
@Test
void whenHasNameMatchingRegexWhenRegexIsNullThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.whenHasNameMatching((String) null))
.withMessage("Regex must not be empty");
}
@Test
void whenHasNameMatchingRegexWhenRegexIsEmptyThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.whenHasNameMatching(""))
.withMessage("Regex must not be empty");
}
@Test
void whenHasNameMatchingRegexWhenNameMatchesCallsGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThat(supplier.whenHasNameMatching("te.*").getSameSite(new Cookie("test", "x"))).isEqualTo(SameSite.LAX);
}
@Test
void whenHasNameMatchingRegexWhenNameDoesNotMatchDoesNotCallGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> fail("Supplier Called");
assertThat(supplier.whenHasNameMatching("te.*").getSameSite(new Cookie("tset", "x"))).isNull();
}
@Test
void whenHasNameMatchingPatternWhenPatternIsNullThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.whenHasNameMatching((Pattern) null))
.withMessage("Pattern must not be null");
}
@Test
void whenHasNameMatchingPatternWhenNameMatchesCallsGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThat(supplier.whenHasNameMatching(Pattern.compile("te.*")).getSameSite(new Cookie("test", "x")))
.isEqualTo(SameSite.LAX);
}
@Test
void whenHasNameMatchingPatternWhenNameDoesNotMatchDoesNotCallGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> fail("Supplier Called");
assertThat(supplier.whenHasNameMatching(Pattern.compile("te.*")).getSameSite(new Cookie("tset", "x"))).isNull();
}
@Test
void whenWhenPredicateIsNullThrowsException() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThatIllegalArgumentException().isThrownBy(() -> supplier.when(null))
.withMessage("Predicate must not be null");
}
@Test
void whenWhenPredicateMatchesCallsGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> SameSite.LAX;
assertThat(supplier.when((cookie) -> cookie.getName().equals("test")).getSameSite(new Cookie("test", "x")))
.isEqualTo(SameSite.LAX);
}
@Test
void whenWhenPredicateDoesNotMatchDoesNotCallGetSameSite() {
CookieSameSiteSupplier supplier = (cookie) -> fail("Supplier Called");
assertThat(supplier.when((cookie) -> cookie.getName().equals("test")).getSameSite(new Cookie("tset", "x")))
.isNull();
}
@Test
void ofNoneSuppliesNone() {
assertThat(CookieSameSiteSupplier.ofNone().getSameSite(new Cookie("test", "x"))).isEqualTo(SameSite.NONE);
}
@Test
void ofLaxSuppliesLax() {
assertThat(CookieSameSiteSupplier.ofLax().getSameSite(new Cookie("test", "x"))).isEqualTo(SameSite.LAX);
}
@Test
void ofStrictSuppliesStrict() {
assertThat(CookieSameSiteSupplier.ofStrict().getSameSite(new Cookie("test", "x"))).isEqualTo(SameSite.STRICT);
}
@Test
void ofWhenNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> CookieSameSiteSupplier.of(null))
.withMessage("SameSite must not be null");
}
@Test
void ofSuppliesValue() {
assertThat(CookieSameSiteSupplier.of(SameSite.STRICT).getSameSite(new Cookie("test", "x")))
.isEqualTo(SameSite.STRICT);
}
}