diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index c0dcbaf2f8..4334461240 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -98,6 +98,7 @@ import org.springframework.security.web.server.csrf.CsrfWebFilter; import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository; import org.springframework.security.web.server.header.CacheControlServerHttpHeadersWriter; import org.springframework.security.web.server.header.CompositeServerHttpHeadersWriter; +import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ContentTypeOptionsServerHttpHeadersWriter; import org.springframework.security.web.server.header.FeaturePolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.HttpHeaderWriterWebFilter; @@ -1664,6 +1665,8 @@ public class ServerHttpSecurity { private FeaturePolicyServerHttpHeadersWriter featurePolicy = new FeaturePolicyServerHttpHeadersWriter(); + private ContentSecurityPolicyServerHttpHeadersWriter contentSecurityPolicy = new ContentSecurityPolicyServerHttpHeadersWriter(); + /** * Allows method chaining to continue configuring the {@link ServerHttpSecurity} * @return the {@link ServerHttpSecurity} to continue configuring @@ -1727,6 +1730,15 @@ public class ServerHttpSecurity { return new XssProtectionSpec(); } + /** + * Configures {@code Content-Security-Policy} response header. + * @param policyDirectives the policy directive(s) + * @return the {@link ContentSecurityPolicySpec} to configure + */ + public ContentSecurityPolicySpec contentSecurityPolicy(String policyDirectives) { + return new ContentSecurityPolicySpec(policyDirectives); + } + /** * Configures {@code Feature-Policy} response header. * @param policyDirectives the policy directive(s) @@ -1868,6 +1880,40 @@ public class ServerHttpSecurity { private XssProtectionSpec() {} } + /** + * Configures {@code Content-Security-Policy} response header. + * + * @see #contentSecurityPolicy(String) + * @since 5.1 + */ + public class ContentSecurityPolicySpec { + + /** + * Whether to include the {@code Content-Security-Policy-Report-Only} header in + * the response. Otherwise, defaults to the {@code Content-Security-Policy} header. + * @param reportOnly whether to only report policy violations + * @return the {@link HeaderSpec} to continue configuring + */ + public HeaderSpec reportOnly(boolean reportOnly) { + HeaderSpec.this.contentSecurityPolicy.setReportOnly(reportOnly); + return HeaderSpec.this; + } + + /** + * Allows method chaining to continue configuring the + * {@link ServerHttpSecurity}. + * @return the {@link HeaderSpec} to continue configuring + */ + public HeaderSpec and() { + return HeaderSpec.this; + } + + private ContentSecurityPolicySpec(String policyDirectives) { + HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); + } + + } + /** * Configures {@code Feature-Policy} response header. * @@ -1894,7 +1940,7 @@ public class ServerHttpSecurity { private HeaderSpec() { this.writers = new ArrayList<>( Arrays.asList(this.cacheControl, this.contentTypeOptions, this.hsts, - this.frameOptions, this.xss, this.featurePolicy)); + this.frameOptions, this.xss, this.featurePolicy, this.contentSecurityPolicy)); } } diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index 7b37e5611f..654a710283 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -27,6 +27,7 @@ import org.junit.Test; import org.springframework.http.HttpHeaders; import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ContentTypeOptionsServerHttpHeadersWriter; import org.springframework.security.web.server.header.FeaturePolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter; @@ -153,11 +154,23 @@ public class HeaderSpecTests { String policyDirectives = "Feature-Policy"; this.expectedHeaders.add(FeaturePolicyServerHttpHeadersWriter.FEATURE_POLICY, policyDirectives); + this.headers.featurePolicy(policyDirectives); assertHeaders(); } + @Test + public void headersWhenContentSecurityPolicyEnabledThenFeaturePolicyWritten() { + String policyDirectives = "default-src 'self'"; + this.expectedHeaders.add(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, + policyDirectives); + + this.headers.contentSecurityPolicy(policyDirectives); + + assertHeaders(); + } + private void expectHeaderNamesNotPresent(String... headerNames) { for(String headerName : headerNames) { this.expectedHeaders.remove(headerName); diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java new file mode 100644 index 0000000000..5c72f547a0 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.security.web.server.header; + +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * Writes the {@code Contet-Security-Policy} response header with configured policy + * directives. + * + * @author Vedran Pavic + * @since 5.1 + */ +public final class ContentSecurityPolicyServerHttpHeadersWriter + implements ServerHttpHeadersWriter { + + public static final String CONTENT_SECURITY_POLICY = "Content-Security-Policy"; + + public static final String CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only"; + + private String policyDirectives; + + private boolean reportOnly; + + private ServerHttpHeadersWriter delegate; + + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return (this.delegate != null) ? this.delegate.writeHttpHeaders(exchange) + : Mono.empty(); + } + + /** + * Set the policy directive(s) to be used in the response header. + * @param policyDirectives the policy directive(s) + * @throws IllegalArgumentException if policyDirectives is {@code null} or empty + */ + public void setPolicyDirectives(String policyDirectives) { + Assert.hasLength(policyDirectives, "policyDirectives must not be null or empty"); + this.policyDirectives = policyDirectives; + this.delegate = createDelegate(); + } + + /** + * Set whether to include the {@code Content-Security-Policy-Report-Only} header in + * the response. Otherwise, defaults to the {@code Content-Security-Policy} header. + * @param reportOnly whether to only report policy violations + */ + public void setReportOnly(boolean reportOnly) { + this.reportOnly = reportOnly; + this.delegate = createDelegate(); + } + + private ServerHttpHeadersWriter createDelegate() { + if (this.policyDirectives != null) { + // @formatter:off + return StaticServerHttpHeadersWriter.builder() + .header(resolveHeader(this.reportOnly), this.policyDirectives) + .build(); + // @formatter:on + } + else { + return null; + } + } + + private static String resolveHeader(boolean reportOnly) { + return reportOnly ? CONTENT_SECURITY_POLICY_REPORT_ONLY : CONTENT_SECURITY_POLICY; + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java new file mode 100644 index 0000000000..b7d37bb820 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.security.web.server.header; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ContentSecurityPolicyServerHttpHeadersWriter}. + * + * @author Vedran Pavic + */ +public class ContentSecurityPolicyServerHttpHeadersWriterTests { + + private static final String DEFAULT_POLICY_DIRECTIVES = "default-src 'self'"; + + private ServerWebExchange exchange; + + private ContentSecurityPolicyServerHttpHeadersWriter writer; + + @Before + public void setup() { + this.exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); + this.writer = new ContentSecurityPolicyServerHttpHeadersWriter(); + } + + @Test + public void writeHeadersWhenUsingDefaultsThenDoesNotWrite() { + this.writer.writeHttpHeaders(this.exchange); + + HttpHeaders headers = this.exchange.getResponse().getHeaders(); + assertThat(headers).isEmpty(); + } + + @Test + public void writeHeadersWhenUsingPolicyThenWritesPolicy() { + this.writer.setPolicyDirectives(DEFAULT_POLICY_DIRECTIVES); + this.writer.writeHttpHeaders(this.exchange); + + HttpHeaders headers = this.exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get( + ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY)) + .containsOnly(DEFAULT_POLICY_DIRECTIVES); + } + + @Test + public void writeHeadersWhenReportPolicyThenWritesReportPolicy() { + this.writer.setPolicyDirectives(DEFAULT_POLICY_DIRECTIVES); + this.writer.setReportOnly(true); + this.writer.writeHttpHeaders(this.exchange); + + HttpHeaders headers = this.exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get( + ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY_REPORT_ONLY)) + .containsOnly(DEFAULT_POLICY_DIRECTIVES); + } + + @Test + public void writeHeadersWhenOnlyReportOnlySetThenDoesNotWrite() { + this.writer.setReportOnly(true); + this.writer.writeHttpHeaders(this.exchange); + + HttpHeaders headers = this.exchange.getResponse().getHeaders(); + assertThat(headers).isEmpty(); + } + + @Test + public void writeHeadersWhenAlreadyWrittenThenWritesHeader() { + String headerValue = "default-src https: 'self'"; + this.exchange.getResponse().getHeaders().set( + ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, + headerValue); + this.writer.writeHttpHeaders(this.exchange); + + HttpHeaders headers = this.exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get( + ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY)) + .containsOnly(headerValue); + } + +}