diff --git a/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java index 881d9b4fee..6e068024e4 100644 --- a/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java @@ -28,6 +28,7 @@ import org.springframework.beans.factory.xml.BeanDefinitionParser; import org.springframework.beans.factory.xml.ParserContext; import org.springframework.security.web.headers.Header; import org.springframework.security.web.headers.HeadersFilter; +import org.springframework.security.web.headers.HstsHeaderWriter; import org.springframework.security.web.headers.StaticHeadersWriter; import org.springframework.security.web.headers.frameoptions.AbstractRequestParameterAllowFromStrategy; import org.springframework.security.web.headers.frameoptions.RegExpAllowFromStrategy; @@ -57,8 +58,14 @@ public class HeadersBeanDefinitionParser implements BeanDefinitionParser { private static final String ATT_VALUE = "value"; private static final String ATT_REF = "ref"; + private static final String ATT_INCLUDE_SUBDOMAINS = "include-subdomains"; + private static final String ATT_MAX_AGE_SECONDS = "max-age-seconds"; + private static final String ATT_REQUEST_MATCHER_REF = "request-matcher-ref"; + private static final String CACHE_CONTROL_ELEMENT = "cache-control"; + private static final String HSTS_ELEMENT = "hsts"; + private static final String XSS_ELEMENT = "xss-protection"; private static final String CONTENT_TYPE_ELEMENT = "content-type-options"; private static final String FRAME_OPTIONS_ELEMENT = "frame-options"; @@ -76,6 +83,7 @@ public class HeadersBeanDefinitionParser implements BeanDefinitionParser { BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(HeadersFilter.class); parseCacheControlElement(element); + parseHstsElement(element); parseXssElement(element, parserContext); parseFrameOptionsElement(element, parserContext); parseContentTypeOptionsElement(element); @@ -119,6 +127,26 @@ public class HeadersBeanDefinitionParser implements BeanDefinitionParser { } } + private void parseHstsElement(Element element) { + Element hstsElement = DomUtils.getChildElementByTagName(element, HSTS_ELEMENT); + if (hstsElement != null) { + BeanDefinitionBuilder headersWriter = BeanDefinitionBuilder.genericBeanDefinition(HstsHeaderWriter.class); + String includeSubDomains = hstsElement.getAttribute(ATT_INCLUDE_SUBDOMAINS); + if(StringUtils.hasText(includeSubDomains)) { + headersWriter.addPropertyValue("includeSubDomains", includeSubDomains); + } + String maxAgeSeconds = hstsElement.getAttribute(ATT_MAX_AGE_SECONDS); + if(StringUtils.hasText(maxAgeSeconds)) { + headersWriter.addPropertyValue("maxAgeInSeconds", maxAgeSeconds); + } + String requestMatcherRef = hstsElement.getAttribute(ATT_REQUEST_MATCHER_REF); + if(StringUtils.hasText(requestMatcherRef)) { + headersWriter.addPropertyReference("requestMatcher", requestMatcherRef); + } + headerWriters.add(headersWriter.getBeanDefinition()); + } + } + private void parseHeaderElements(Element element) { List headerElts = DomUtils.getChildElementsByTagName(element, GENERIC_HEADER_ELEMENT); for (Element headerElt : headerElts) { diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-3.2.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-3.2.rnc index 80d56371a7..36eb764343 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-3.2.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-3.2.rnc @@ -720,7 +720,20 @@ jdbc-user-service.attlist &= headers = ## Element for configuration of the AddHeadersFilter. Enables easy setting for the X-Frame-Options, X-XSS-Protection and X-Content-Type-Options headers. - element headers {cache-control? & xss-protection? & frame-options? & content-type-options? & header*} + element headers {cache-control? & xss-protection? & hsts? & frame-options? & content-type-options? & header*} + +hsts = + ## Adds support for HTTP Strict Transport Security (HSTS) + element hsts {hsts-options.attlist} +hsts-options.attlist &= + ## Specifies if subdomains should be included. Default true. + attribute include-subdomains {xsd:boolean}? +hsts-options.attlist &= + ## Specifies the maximum ammount of time the host should be considered a Known HSTS Host. Default one year. + attribute max-age-seconds {xsd:integer}? +hsts-options.attlist &= + ## The RequestMatcher instance to be used to determine if the header should be set. Default is if HttpServletRequest.isSecure() is true. + attribute request-matcher-ref { xsd:token }? cache-control = ## Adds Cache-Control no-cache, no-store, must-revalidate and Pragma no-cache every URL diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-3.2.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-3.2.xsd index ba73ae02d0..04259b9076 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-3.2.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-3.2.xsd @@ -2242,12 +2242,44 @@ + + + + Adds support for HTTP Strict Transport Security (HSTS) + + + + + + + + + + Specifies if subdomains should be included. Default true. + + + + + + Specifies the maximum ammount of time the host should be considered a Known HSTS Host. + Default one year. + + + + + + The RequestMatcher instance to be used to determine if the header should be set. Default + is if HttpServletRequest.isSecure() is true. + + + + Adds Cache-Control no-cache, no-store, must-revalidate and Pragma no-cache every URL diff --git a/config/src/test/groovy/org/springframework/security/config/http/HttpHeadersConfigTests.groovy b/config/src/test/groovy/org/springframework/security/config/http/HttpHeadersConfigTests.groovy index aeeca2d855..7879c72469 100644 --- a/config/src/test/groovy/org/springframework/security/config/http/HttpHeadersConfigTests.groovy +++ b/config/src/test/groovy/org/springframework/security/config/http/HttpHeadersConfigTests.groovy @@ -35,6 +35,7 @@ import org.springframework.security.web.authentication.ui.DefaultLoginPageGenera import org.springframework.security.web.headers.HeadersFilter import org.springframework.security.web.headers.StaticHeadersWriter; import org.springframework.security.web.headers.frameoptions.StaticAllowFromStrategy; +import org.springframework.security.web.util.AnyRequestMatcher; /** * @@ -341,6 +342,56 @@ class HttpHeadersConfigTests extends AbstractHttpConfigTests { assertHeaders(response, ['Cache-Control': 'no-cache,no-store,max-age=0,must-revalidate','Pragma':'no-cache']) } + def 'http headers hsts'() { + setup: + httpAutoConfig { + 'headers'() { + 'hsts'() + } + } + createAppContext() + def springSecurityFilterChain = appContext.getBean(FilterChainProxy) + MockHttpServletResponse response = new MockHttpServletResponse() + when: + springSecurityFilterChain.doFilter(new MockHttpServletRequest(secure:true), response, new MockFilterChain()) + then: + assertHeaders(response, ['Strict-Transport-Security': 'max-age=31536000 ; includeSubDomains']) + } + + def 'http headers hsts default only invokes on HttpServletRequest.isSecure = true'() { + setup: + httpAutoConfig { + 'headers'() { + 'hsts'() + } + } + createAppContext() + def springSecurityFilterChain = appContext.getBean(FilterChainProxy) + MockHttpServletResponse response = new MockHttpServletResponse() + when: + springSecurityFilterChain.doFilter(new MockHttpServletRequest(), response, new MockFilterChain()) + then: + response.headerNames.empty + } + + def 'http headers hsts custom'() { + setup: + httpAutoConfig { + 'headers'() { + 'hsts'('max-age-seconds':'1','include-subdomains':false, 'request-matcher-ref' : 'matcher') + } + } + + xml.'b:bean'(id: 'matcher', 'class': AnyRequestMatcher.name) + createAppContext() + def springSecurityFilterChain = appContext.getBean(FilterChainProxy) + MockHttpServletResponse response = new MockHttpServletResponse() + when: + springSecurityFilterChain.doFilter(new MockHttpServletRequest(), response, new MockFilterChain()) + then: + assertHeaders(response, ['Strict-Transport-Security': 'max-age=1']) + } + def assertHeaders(MockHttpServletResponse response, Map expected) { assert response.headerNames == expected.keySet() expected.each { headerName, value -> diff --git a/docs/manual/src/docbook/appendix-namespace.xml b/docs/manual/src/docbook/appendix-namespace.xml index f6ec43716a..a6f518e7e6 100644 --- a/docs/manual/src/docbook/appendix-namespace.xml +++ b/docs/manual/src/docbook/appendix-namespace.xml @@ -267,6 +267,9 @@ Cache-Control and Pragma - Can be set using the cache-control element. This ensures that the browser does not cache your secured pages. + Strict-Transport-Security - Can be set using the + hsts element. This ensures that the + browser automatically requests HTTPS for future requests. X-Frame-Options - Can be set using the frame-options element. The X-Frame-Options @@ -295,6 +298,7 @@ content-type-options frame-options header + hsts xss-protection @@ -310,6 +314,38 @@ +
+ <literal><hsts></literal> + When enabled adds the Strict-Transport-Security header to the response + for any secure request. This allows the server to instruct browsers to automatically use HTTPS for future requests. +
+ <literal><hsts></literal> Attributes +
+ <literal>include-sub-domains</literal> + + Specifies if subdomains should be included. Default true. + +
+
+ <literal>max-age-seconds</literal> + + Specifies the maximum ammount of time the host should be considered a Known HSTS Host. Default one year. + +
+
+ <literal>request-matcher-ref</literal> + + The RequestMatcher instance to be used to determine if the header should be set. Default is if HttpServletRequest.isSecure() is true. + +
+
+
+ Parent Elements of <literal><hsts></literal> + + headers + +
+
<literal><frame-options></literal> When enabled adds the X-Frame-Options header to the response, this allows newer browsers to do some security diff --git a/web/src/main/java/org/springframework/security/web/headers/HstsHeaderWriter.java b/web/src/main/java/org/springframework/security/web/headers/HstsHeaderWriter.java new file mode 100644 index 0000000000..2e79049536 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/headers/HstsHeaderWriter.java @@ -0,0 +1,165 @@ +/* + * Copyright 2002-2013 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.headers; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.web.util.RequestMatcher; +import org.springframework.util.Assert; + +/** + * Provides support for HTTP Strict + * Transport Security (HSTS). + * + *

+ * By default the expiration is one year and subdomains will be included. This + * can be customized using {@link #setMaxAgeInSeconds(long)} and + * {@link #setIncludeSubDomains(boolean)} respectively. + *

+ * + *

+ * Since section + * 7.2 states that HSTS Host MUST NOT include the STS header in HTTP + * responses, the default behavior is that the "Strict-Transport-Security" will + * only be added when {@link HttpServletRequest#isSecure()} returns {@code true} + * . At times this may need to be customized. For example, in some situations + * where SSL termination is used, something else may be used to determine if SSL + * was used. For these circumstances, {@link #setRequestMatcher(RequestMatcher)} + * can be invoked with a custom {@link RequestMatcher}. + *

+ * + * @author Rob Winch + * @since 3.2 + */ +public final class HstsHeaderWriter implements HeaderWriter { + private static final String HSTS_HEADER_NAME = "Strict-Transport-Security"; + + private final Log logger = LogFactory.getLog(getClass()); + + private RequestMatcher requestMatcher = new SecureRequestMatcher(); + + private long maxAgeInSeconds; + + private boolean includeSubDomains; + + private String hstsHeaderValue; + + public HstsHeaderWriter() { + this.maxAgeInSeconds = 31536000; + this.includeSubDomains = true; + updateHstsHeaderValue(); + } + + /* + * (non-Javadoc) + * + * @see + * org.springframework.security.web.headers.HeaderWriter#writeHeaders(javax + * .servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse) + */ + @Override + public void writeHeaders(HttpServletRequest request, + HttpServletResponse response) { + if (requestMatcher.matches(request)) { + response.setHeader(HSTS_HEADER_NAME, hstsHeaderValue); + } else if (logger.isDebugEnabled()) { + logger.debug("Not injecting HSTS header since it did not match the requestMatcher " + + requestMatcher); + } + } + + /** + * Sets the {@link RequestMatcher} used to determine if the + * "Strict-Transport-Security" should be added. If true the header is added, + * else the header is not added. By default the header is added when + * {@link HttpServletRequest#isSecure()} returns true. + * + * @param requestMatcher + * the {@link RequestMatcher} to use. + * @throws IllegalArgumentException + * if {@link RequestMatcher} is null + */ + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + + /** + *

+ * Sets the value (in seconds) for the max-age directive of the + * Strict-Transport-Security header. The default is one year. + *

+ * + *

+ * This instructs browsers how long to remember to keep this domain as a + * known HSTS Host. See Section 6.1.1 + * for additional details. + *

+ * + * @param maxAgeInSeconds + * the maximum amount of time (in seconds) to consider this + * domain as a known HSTS Host. + * @throws IllegalArgumentException + * if maxAgeInSeconds is negative + */ + public void setMaxAgeInSeconds(long maxAgeInSeconds) { + if (maxAgeInSeconds < 0) { + throw new IllegalArgumentException( + "maxAgeInSeconds must be non-negative. Got " + + maxAgeInSeconds); + } + this.maxAgeInSeconds = maxAgeInSeconds; + updateHstsHeaderValue(); + } + + /** + *

+ * If true, subdomains should be considered HSTS Hosts too. The default is + * true. + *

+ * + *

+ * See Section + * 6.1.2 for additional details. + *

+ * + * @param includeSubDomains + * true to include subdomains, else false + */ + public void setIncludeSubDomains(boolean includeSubDomains) { + this.includeSubDomains = includeSubDomains; + updateHstsHeaderValue(); + } + + private void updateHstsHeaderValue() { + String headerValue = "max-age=" + maxAgeInSeconds; + if (includeSubDomains) { + headerValue += " ; includeSubDomains"; + } + this.hstsHeaderValue = headerValue; + } + + private static final class SecureRequestMatcher implements RequestMatcher { + @Override + public boolean matches(HttpServletRequest request) { + return request.isSecure(); + } + } +} diff --git a/web/src/test/java/org/springframework/security/web/headers/HstsHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/headers/HstsHeaderWriterTests.java new file mode 100644 index 0000000000..8182396e1e --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/headers/HstsHeaderWriterTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2013 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.headers; + +import static org.fest.assertions.Assertions.assertThat; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * @author Rob Winch + * + */ +public class HstsHeaderWriterTests { + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + private HstsHeaderWriter writer; + + @Before + public void setup() { + request = new MockHttpServletRequest(); + request.setSecure(true); + response = new MockHttpServletResponse(); + + writer = new HstsHeaderWriter(); + } + + @Test + public void writeHeadersDefaultValues() { + writer.writeHeaders(request, response); + + assertThat(response.getHeaderNames().size()).isEqualTo(1); + assertThat(response.getHeader("Strict-Transport-Security")).isEqualTo("max-age=31536000 ; includeSubDomains"); + } + + @Test + public void writeHeadersIncludeSubDomainsFalse() { + writer.setIncludeSubDomains(false); + + writer.writeHeaders(request, response); + + assertThat(response.getHeaderNames().size()).isEqualTo(1); + assertThat(response.getHeader("Strict-Transport-Security")).isEqualTo("max-age=31536000"); + } + + @Test + public void writeHeadersCustomMaxAgeInSeconds() { + writer.setMaxAgeInSeconds(1); + + writer.writeHeaders(request, response); + + assertThat(response.getHeaderNames().size()).isEqualTo(1); + assertThat(response.getHeader("Strict-Transport-Security")).isEqualTo("max-age=1 ; includeSubDomains"); + } + + @Test + public void writeHeadersInsecureRequestDoesNotWriteHeader() { + request.setSecure(false); + + writer.writeHeaders(request, response); + + assertThat(response.getHeaderNames().isEmpty()).isTrue(); + } + + @Test(expected = IllegalArgumentException.class) + public void setMaxAgeInSecondsToNegative() { + writer.setMaxAgeInSeconds(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void setRequestMatcherToNull() { + writer.setRequestMatcher(null); + } +}