Add default configurable Grails security response headers#15967
Add default configurable Grails security response headers#15967jamesfredley wants to merge 5 commits into
Conversation
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]
There was a problem hiding this comment.
Pull request overview
This PR introduces a default, configurable servlet filter in grails-controllers that applies baseline browser-hardening response headers for Grails 8 servlet web applications, along with auto-configuration, configuration metadata, tests, and documentation updates in the user guide and upgrade notes.
Changes:
- Added
GrailsSecurityHeadersFilterplusGrailsSecurityHeadersPropertiesand a servlet-only Boot auto-configuration to register it by default (with opt-out viagrails.security.headers.*). - Added configuration metadata for IDE/property hinting and documented the defaults + customization in the Security guide and Grails 8 upgrade notes.
- Added a Spock spec verifying auto-config behavior, default headers, per-header disablement, overrides, and secure-request HSTS behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| grails-doc/src/en/guide/upgrading/upgrading80x.adoc | Adds Grails 8 upgrade note about the new default security headers filter and how to disable/configure it. |
| grails-doc/src/en/guide/security.adoc | Documents default headers, rationale for HSTS/CSP being opt-in, and example application.yml configuration. |
| grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfigurationSpec.groovy | Adds test coverage for auto-configuration and header application behavior. |
| grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | Registers the new auto-configuration class for Spring Boot discovery. |
| grails-controllers/src/main/resources/META-INF/additional-spring-configuration-metadata.json | Adds Spring configuration metadata entries for grails.security.headers.* properties. |
| grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersProperties.java | Introduces bindable configuration properties and defaults for each supported header. |
| grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersFilter.java | Implements the OncePerRequestFilter that applies headers conditionally based on configuration and existing response headers. |
| grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersAutoConfiguration.java | Adds servlet-only auto-config with conditional registration and a FilterRegistrationBean for ordering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
The proposed change to move header application into a grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsSecurityHeadersFilter.java |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15967 +/- ##
================================================
+ Coverage 0 51.1192% +51.1192%
- Complexity 0 17611 +17611
================================================
Files 0 2044 +2044
Lines 0 95559 +95559
Branches 0 16589 +16589
================================================
+ Hits 0 48849 +48849
- Misses 0 39424 +39424
- Partials 0 7286 +7286
🚀 New features to boost your workflow:
|
The Copilot suggestion to apply the headers in a post-chain finally block would skip every security header on responses that commit during the chain (sendRedirect, sendError, flushBuffer, streaming), which is exactly where the headers are needed. Keep applying the headers eagerly before the filter chain (values already present still win, explicit downstream setHeader still replaces) and add a regression test proving the headers are present on a redirect-committed response. Assisted-by: Sisyphus:openai/gpt-5.6-terra [gpt-coding]
OncePerRequestFilter.shouldNotFilterErrorDispatch() defaults to true, so the filter was skipped on ERROR redispatches even though the ERROR dispatcher type is registered, leaving error responses without the security headers. Override it to return false and cover the behavior with a DispatcherType.ERROR test. Assisted-by: Sisyphus:openai/gpt-5.6-terra [gpt-coding]
Review feedback addressedMerged the latest Copilot review comment - apply headers in a
Follow-up (documented, not changed here): when the optional Spring Security plugin is installed, its header writers skip headers already present, so eagerly-set Grails defaults can win over a Spring-Security-configured policy. The robust fix is an on-commit response wrapper that fills only-missing headers after downstream writers run; that is a larger change and Spring Security is an optional plugin, so I left it as a follow-up rather than expand this starter filter's scope. Local verification: |
jdaugherty
left a comment
There was a problem hiding this comment.
Took a look at this with reverse-proxy deployments (nginx/haproxy) in mind, since that's how most production Grails apps run. A few concerns inline — the common thread is that the containsHeader guard only protects against headers set inside the servlet container, but in real deployments these headers are frequently owned by the edge proxy or by Spring Security, and in both cases the current behavior is surprising.
| private static void applyHeader(HttpServletResponse response, String name, | ||
| GrailsSecurityHeadersProperties.Header header) { | ||
| if (header != null && header.isEnabled() && StringUtils.hasText(header.getValue()) && | ||
| !response.containsHeader(name)) { |
There was a problem hiding this comment.
The containsHeader guard can't see headers injected by a reverse proxy, because those are added after the response leaves the app. In the very common setup where nginx already sends these headers via add_header (which appends and never replaces upstream headers), upgrading to 8.0 means the client suddenly receives the header twice — e.g. proxy X-Frame-Options: DENY plus app SAMEORIGIN. Browsers handle conflicting duplicates inconsistently: duplicate conflicting X-Frame-Options causes Chrome to block framing outright, duplicate Referrer-Policy resolves to the last valid value, and multiple Content-Security-Policy headers are enforced as the intersection of all policies (strictly tighter, can break pages). haproxy http-response set-header replaces, so it's unaffected, but nginx add_header deployments will double-send by default.
Since this is on by default, existing proxy-managed deployments change behavior silently on upgrade. At minimum the security guide and the 8.0 upgrade notes should call this out explicitly with the mitigation (set grails.security.headers.enabled: false — or per-header enabled: false — when the edge proxy owns these headers, or strip the app's copies at the proxy). Worth also considering whether on-by-default is the right trade-off for an upgrade release given this.
| applyHeader(response, "X-Frame-Options", properties.getFrameOptions()); | ||
| applyHeader(response, "Referrer-Policy", properties.getReferrerPolicy()); | ||
| applyHeader(response, "X-XSS-Protection", properties.getXssProtection()); | ||
| if (request.isSecure()) { |
There was a problem hiding this comment.
Behind a TLS-terminating proxy (the typical nginx/haproxy topology), the backend connection is plain HTTP, so request.isSecure() is false and HSTS is silently never sent — even when the user has explicitly set grails.security.headers.hsts.enabled: true. It only works if the app is also configured with server.forward-headers-strategy: framework|native so X-Forwarded-Proto is honored (the ForwardedHeaderFilter / RemoteIpValve run well before this filter, so ordering is fine once that's set).
The docs currently just say HSTS is sent "only for secure requests" — that should spell out the proxy case: either configure server.forward-headers-strategy, or (usually better) set HSTS at the proxy where TLS actually terminates. Otherwise enabling HSTS here is a silent no-op for most real deployments.
| 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. |
There was a problem hiding this comment.
I believe this claim is inverted for Spring Security. This filter registers at GrailsFilters.LAST (order -110), which runs before the Spring Security filter chain (default order -100), and it sets its headers eagerly on the way in. Spring Security's header writers (XFrameOptionsHeaderWriter, HstsHeaderWriter, StaticHeadersWriter — verified in spring-security-web 7.0.4 sources) all skip writing when the header is already present on the response.
So an app that configures e.g. frameOptions { deny() } in its security config will still send the Grails default SAMEORIGIN after upgrading — the Grails filter wins, not Spring Security. Same for a customized HSTS policy. That's a behavioral regression for existing Spring Security users, and the opposite of what this sentence promises. Options: back off when Spring Security is on the classpath (it already provides secure defaults for these headers), or write the headers at commit time only when still absent (the HeaderWriterFilter pattern) so anything set later in the chain wins. If the current precedence is intentional, this doc needs to say the opposite of what it says now.
|
The reverse proxy scenarios are real regressions that need addressed. |
Assisted-by: opencode:gpt-5.6-sol
✅ All tests passed ✅🏷️ Commit: e38c2e7 Learn more about TestLens at testlens.app. |
Description
What was found
What changed
GrailsSecurityHeadersFilter(OncePerRequestFilter)GrailsSecurityHeadersAutoConfigurationwith@ConditionalOnMissingBeanX-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin,X-XSS-Protection: 0grails.security.headers.*enable/disable and per-header valuesReview feedback addressed
finallyblock after the chaincontainsHeadergap-fill (downstreamsetHeaderstill wins) + added a redirect-committed regression testshouldNotFilterErrorDispatch()tofalse+DispatcherType.ERRORtestOut of scope / follow-up
Related MD topics
Contributor Checklist
Issue and Scope
8.0.x.Code Quality
Licensing and Attribution
ai-generated-starting-pointlabel applied.Documentation
Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]