This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch feat/default-security-headers in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 4bc02bf54cd996a064fce51ae37bed7897d7fe38 Author: James Fredley <[email protected]> AuthorDate: Fri Jul 10 18:07:54 2026 -0400 Add default configurable Grails security response headers Register a OncePerRequestFilter for X-Content-Type-Options, X-Frame-Options, Referrer-Policy, optional HSTS on secure requests, and optional CSP. Configurable via grails.security.headers.* with opt-out and ConditionalOnMissingBean. Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding] --- .../GrailsSecurityHeadersAutoConfiguration.java | 59 ++++++++ .../controllers/GrailsSecurityHeadersFilter.java | 60 +++++++++ .../GrailsSecurityHeadersProperties.java | 126 +++++++++++++++++ .../additional-spring-configuration-metadata.json | 81 +++++++++++ ...rk.boot.autoconfigure.AutoConfiguration.imports | 1 + ...ailsSecurityHeadersAutoConfigurationSpec.groovy | 150 +++++++++++++++++++++ grails-doc/src/en/guide/security.adoc | 50 +++++++ .../src/en/guide/upgrading/upgrading80x.adoc | 4 + 8 files changed, 531 insertions(+) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfiguration.java new file mode 100644 index 0000000000..7b07018ed0 --- /dev/null +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfiguration.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.plugins.web.controllers; + +import java.util.EnumSet; + +import jakarta.servlet.DispatcherType; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; + +import org.grails.web.config.http.GrailsFilters; + +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnBooleanProperty(name = "grails.security.headers.enabled", matchIfMissing = true) +@EnableConfigurationProperties(GrailsSecurityHeadersProperties.class) +public class GrailsSecurityHeadersAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(value = GrailsSecurityHeadersFilter.class, name = "grailsSecurityHeadersFilter") + public GrailsSecurityHeadersFilter securityHeadersFilter(GrailsSecurityHeadersProperties properties) { + return new GrailsSecurityHeadersFilter(properties); + } + + @Bean + @ConditionalOnMissingBean(name = "grailsSecurityHeadersFilter") + public FilterRegistrationBean<GrailsSecurityHeadersFilter> grailsSecurityHeadersFilter( + GrailsSecurityHeadersFilter securityHeadersFilter) { + FilterRegistrationBean<GrailsSecurityHeadersFilter> registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(securityHeadersFilter); + registrationBean.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, + DispatcherType.INCLUDE, DispatcherType.ERROR)); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(GrailsFilters.LAST.getOrder()); + return registrationBean; + } +} diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersFilter.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersFilter.java new file mode 100644 index 0000000000..450efbcfc5 --- /dev/null +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersFilter.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.plugins.web.controllers; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +public class GrailsSecurityHeadersFilter extends OncePerRequestFilter { + + private final GrailsSecurityHeadersProperties properties; + + public GrailsSecurityHeadersFilter(GrailsSecurityHeadersProperties properties) { + this.properties = properties; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + applyHeader(response, "X-Content-Type-Options", properties.getContentTypeOptions()); + applyHeader(response, "X-Frame-Options", properties.getFrameOptions()); + applyHeader(response, "Referrer-Policy", properties.getReferrerPolicy()); + applyHeader(response, "X-XSS-Protection", properties.getXssProtection()); + if (request.isSecure()) { + applyHeader(response, "Strict-Transport-Security", properties.getHsts()); + } + applyHeader(response, "Content-Security-Policy", properties.getContentSecurityPolicy()); + filterChain.doFilter(request, response); + } + + private static void applyHeader(HttpServletResponse response, String name, + GrailsSecurityHeadersProperties.Header header) { + if (header != null && header.isEnabled() && StringUtils.hasText(header.getValue()) && + !response.containsHeader(name)) { + response.setHeader(name, header.getValue()); + } + } +} diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersProperties.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersProperties.java new file mode 100644 index 0000000000..2ba0027307 --- /dev/null +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersProperties.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.plugins.web.controllers; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "grails.security.headers") +public class GrailsSecurityHeadersProperties { + + private boolean enabled = true; + + private Header contentTypeOptions = new Header(true, "nosniff"); + + private Header frameOptions = new Header(true, "SAMEORIGIN"); + + private Header referrerPolicy = new Header(true, "strict-origin-when-cross-origin"); + + private Header xssProtection = new Header(true, "0"); + + private Header hsts = new Header(false, "max-age=31536000"); + + private Header contentSecurityPolicy = new Header(false, null); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Header getContentTypeOptions() { + return contentTypeOptions; + } + + public void setContentTypeOptions(Header contentTypeOptions) { + this.contentTypeOptions = contentTypeOptions; + } + + public Header getFrameOptions() { + return frameOptions; + } + + public void setFrameOptions(Header frameOptions) { + this.frameOptions = frameOptions; + } + + public Header getReferrerPolicy() { + return referrerPolicy; + } + + public void setReferrerPolicy(Header referrerPolicy) { + this.referrerPolicy = referrerPolicy; + } + + public Header getXssProtection() { + return xssProtection; + } + + public void setXssProtection(Header xssProtection) { + this.xssProtection = xssProtection; + } + + public Header getHsts() { + return hsts; + } + + public void setHsts(Header hsts) { + this.hsts = hsts; + } + + public Header getContentSecurityPolicy() { + return contentSecurityPolicy; + } + + public void setContentSecurityPolicy(Header contentSecurityPolicy) { + this.contentSecurityPolicy = contentSecurityPolicy; + } + + public static class Header { + + private boolean enabled; + + private String value; + + public Header() { + } + + Header(boolean enabled, String value) { + this.enabled = enabled; + this.value = value; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } +} diff --git a/grails-controllers/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-controllers/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000000..b149205a8b --- /dev/null +++ b/grails-controllers/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,81 @@ +{ + "properties": [ + { + "name": "grails.security.headers.enabled", + "description": "Whether Grails registers the default servlet HTTP security headers filter.", + "type": "java.lang.Boolean", + "defaultValue": true + }, + { + "name": "grails.security.headers.content-type-options.enabled", + "description": "Whether to send the X-Content-Type-Options response header.", + "type": "java.lang.Boolean", + "defaultValue": true + }, + { + "name": "grails.security.headers.content-type-options.value", + "description": "Value for the X-Content-Type-Options response header.", + "type": "java.lang.String", + "defaultValue": "nosniff" + }, + { + "name": "grails.security.headers.frame-options.enabled", + "description": "Whether to send the X-Frame-Options response header.", + "type": "java.lang.Boolean", + "defaultValue": true + }, + { + "name": "grails.security.headers.frame-options.value", + "description": "Value for the X-Frame-Options response header.", + "type": "java.lang.String", + "defaultValue": "SAMEORIGIN" + }, + { + "name": "grails.security.headers.referrer-policy.enabled", + "description": "Whether to send the Referrer-Policy response header.", + "type": "java.lang.Boolean", + "defaultValue": true + }, + { + "name": "grails.security.headers.referrer-policy.value", + "description": "Value for the Referrer-Policy response header.", + "type": "java.lang.String", + "defaultValue": "strict-origin-when-cross-origin" + }, + { + "name": "grails.security.headers.xss-protection.enabled", + "description": "Whether to send the X-XSS-Protection response header.", + "type": "java.lang.Boolean", + "defaultValue": true + }, + { + "name": "grails.security.headers.xss-protection.value", + "description": "Value for the X-XSS-Protection response header.", + "type": "java.lang.String", + "defaultValue": "0" + }, + { + "name": "grails.security.headers.hsts.enabled", + "description": "Whether to send Strict-Transport-Security on secure requests.", + "type": "java.lang.Boolean", + "defaultValue": false + }, + { + "name": "grails.security.headers.hsts.value", + "description": "Value for the Strict-Transport-Security response header when HSTS is enabled and the request is secure.", + "type": "java.lang.String", + "defaultValue": "max-age=31536000" + }, + { + "name": "grails.security.headers.content-security-policy.enabled", + "description": "Whether to send the Content-Security-Policy response header.", + "type": "java.lang.Boolean", + "defaultValue": false + }, + { + "name": "grails.security.headers.content-security-policy.value", + "description": "Value for the Content-Security-Policy response header when CSP is enabled.", + "type": "java.lang.String" + } + ] +} diff --git a/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 378329354a..9884185c58 100644 --- a/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ org.grails.plugins.web.controllers.ControllersAutoConfiguration org.grails.plugins.web.controllers.GrailsFormContentFilterAutoConfiguration +org.grails.plugins.web.controllers.GrailsSecurityHeadersAutoConfiguration org.grails.plugins.web.controllers.GrailsViewResolverAutoConfiguration org.grails.plugins.web.controllers.GrailsWelcomePageAutoConfiguration diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfigurationSpec.groovy new file mode 100644 index 0000000000..b1dbf35998 --- /dev/null +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfigurationSpec.groovy @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.plugins.web.controllers + +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.boot.test.context.runner.WebApplicationContextRunner +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +import spock.lang.Specification + +class GrailsSecurityHeadersAutoConfigurationSpec extends Specification { + + void 'default servlet web auto-configuration registers the security headers filter'() { + expect: + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrailsSecurityHeadersAutoConfiguration)) + .run { context -> + assert context.getBeanNamesForType(GrailsSecurityHeadersFilter).length == 1 + assert context.getBean('grailsSecurityHeadersFilter') instanceof FilterRegistrationBean + } + } + + void 'security headers auto-configuration does not run for non-web applications'() { + expect: + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrailsSecurityHeadersAutoConfiguration)) + .run { context -> + assert context.getBeanNamesForType(GrailsSecurityHeadersFilter).length == 0 + } + } + + void 'security headers auto-configuration can be disabled'() { + expect: + new WebApplicationContextRunner() + .withPropertyValues('grails.security.headers.enabled=false') + .withConfiguration(AutoConfigurations.of(GrailsSecurityHeadersAutoConfiguration)) + .run { context -> + assert context.getBeanNamesForType(GrailsSecurityHeadersFilter).length == 0 + } + } + + void 'application-defined security headers filter makes the auto-configured filter back off'() { + given: + def userFilter = new GrailsSecurityHeadersFilter(new GrailsSecurityHeadersProperties()) + + expect: + new WebApplicationContextRunner() + .withBean(GrailsSecurityHeadersFilter) { userFilter } + .withConfiguration(AutoConfigurations.of(GrailsSecurityHeadersAutoConfiguration)) + .run { context -> + assert context.getBean(GrailsSecurityHeadersFilter).is(userFilter) + assert context.getBeanNamesForType(GrailsSecurityHeadersFilter).length == 1 + } + } + + void 'application-defined security headers registration makes the raw filter back off'() { + given: + def userRegistration = new FilterRegistrationBean() + + expect: + new WebApplicationContextRunner() + .withBean('grailsSecurityHeadersFilter', FilterRegistrationBean) { userRegistration } + .withConfiguration(AutoConfigurations.of(GrailsSecurityHeadersAutoConfiguration)) + .run { context -> + assert context.getBean('grailsSecurityHeadersFilter').is(userRegistration) + assert context.getBeanNamesForType(GrailsSecurityHeadersFilter).length == 0 + } + } + + void 'default filter writes browser hardening headers and skips disabled optional headers'() { + given: + def request = new MockHttpServletRequest('GET', '/') + def response = new MockHttpServletResponse() + def filter = new GrailsSecurityHeadersFilter(new GrailsSecurityHeadersProperties()) + + when: + filter.doFilter(request, response, new MockFilterChain()) + + then: + response.getHeader('X-Content-Type-Options') == 'nosniff' + response.getHeader('X-Frame-Options') == 'SAMEORIGIN' + response.getHeader('Referrer-Policy') == 'strict-origin-when-cross-origin' + response.getHeader('X-XSS-Protection') == '0' + response.getHeader('Strict-Transport-Security') == null + response.getHeader('Content-Security-Policy') == null + } + + void 'filter applies configured overrides and optional headers'() { + given: + def request = new MockHttpServletRequest('GET', '/') + request.secure = true + def response = new MockHttpServletResponse() + def properties = new GrailsSecurityHeadersProperties() + properties.frameOptions.value = 'DENY' + properties.referrerPolicy.value = 'no-referrer-when-downgrade' + properties.hsts.enabled = true + properties.hsts.value = 'max-age=63072000; includeSubDomains' + properties.contentSecurityPolicy.enabled = true + properties.contentSecurityPolicy.value = "default-src 'self'" + + when: + new GrailsSecurityHeadersFilter(properties).doFilter(request, response, new MockFilterChain()) + + then: + response.getHeader('X-Frame-Options') == 'DENY' + response.getHeader('Referrer-Policy') == 'no-referrer-when-downgrade' + response.getHeader('Strict-Transport-Security') == 'max-age=63072000; includeSubDomains' + response.getHeader('Content-Security-Policy') == "default-src 'self'" + } + + void 'filter respects per-header disable switches and existing response headers'() { + given: + def request = new MockHttpServletRequest('GET', '/') + def response = new MockHttpServletResponse() + response.setHeader('X-Frame-Options', 'DENY') + def properties = new GrailsSecurityHeadersProperties() + properties.contentTypeOptions.enabled = false + properties.xssProtection.enabled = false + + when: + new GrailsSecurityHeadersFilter(properties).doFilter(request, response, new MockFilterChain()) + + then: + response.getHeader('X-Content-Type-Options') == null + response.getHeader('X-XSS-Protection') == null + response.getHeader('X-Frame-Options') == 'DENY' + response.getHeader('Referrer-Policy') == 'strict-origin-when-cross-origin' + } +} diff --git a/grails-doc/src/en/guide/security.adoc b/grails-doc/src/en/guide/security.adoc index bf8bcd225f..dae6dad964 100644 --- a/grails-doc/src/en/guide/security.adoc +++ b/grails-doc/src/en/guide/security.adoc @@ -31,3 +31,53 @@ Grails has a few built in safety mechanisms by default. * The default link:scaffolding.html[scaffolding] templates HTML escape all data fields when displayed * Grails link creating tags (link:{gspTagsRef}link.html[link], link:{gspTagsRef}form.html[form], link:{gspTagsRef}createLink.html[createLink], link:{gspTagsRef}createLinkTo.html[createLinkTo] and others) all use appropriate escaping mechanisms to prevent code injection * Grails provides <<codecs,codecs>> to let you trivially escape data when rendered as HTML, JavaScript and URLs to prevent injection attacks here. + +==== HTTP Security Headers + +Grails servlet web applications send a small set of browser hardening headers by default: + +[cols="1,1", options="header"] +|=== +| Header +| Default value + +| `X-Content-Type-Options` +| `nosniff` + +| `X-Frame-Options` +| `SAMEORIGIN` + +| `Referrer-Policy` +| `strict-origin-when-cross-origin` + +| `X-XSS-Protection` +| `0` +|=== + +HSTS and Content Security Policy are not enabled by default because their correct values depend on deployment topology and application assets. +When HSTS is enabled, Grails sends `Strict-Transport-Security` only for secure requests. + +You can disable the whole filter, disable individual headers, or override values from `application.yml`: + +[source,yaml] +.grails-app/conf/application.yml +---- +grails: + security: + headers: + enabled: true + frame-options: + value: DENY + referrer-policy: + value: no-referrer-when-downgrade + hsts: + enabled: true + value: max-age=31536000; includeSubDomains + content-security-policy: + enabled: true + value: default-src 'self' +---- + +Set `grails.security.headers.<header>.enabled` to `false` to omit a single header. +For example, `grails.security.headers.xss-protection.enabled: false` omits `X-XSS-Protection`. +If an application or another filter has already set one of these headers on the response, Grails leaves that value unchanged. diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index eb80fee140..f18cfa2b64 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -537,6 +537,10 @@ Spring Security is moving toward the fluent `HttpSecurity` configuration API for If your application references `DEFAULT_FILTER_ORDER` for custom filter positioning, replace it with the concrete value `-100` (computed as `OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100`). Longer term, consider migrating to the fluent `HttpSecurity` API for filter chain configuration. +Grails 8 also registers a servlet filter that sends default browser hardening headers for servlet web applications. +Applications that already set these headers through Spring Security or a custom filter keep their existing response values. +To disable or customize the Grails defaults, configure `grails.security.headers.*`; see the Security guide's HTTP Security Headers section for the full list. + ==== 14. Spring Dependency Management Plugin Replaced by Gradle Platforms Grails 8 standardizes on Gradle's native `platform()` dependency management and **no longer applies the `io.spring.dependency-management` plugin**.
