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**.

Reply via email to