Add default configurable Grails security response headers - #15967
Add default configurable Grails security response headers#15967jamesfredley wants to merge 7 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 52.3514% 52.3591% +0.0077%
- Complexity 18299 18310 +11
==================================================
Files 2036 2039 +3
Lines 96347 96413 +66
Branches 16829 16831 +2
==================================================
+ Hits 50439 50481 +42
- Misses 38485 38507 +22
- Partials 7423 7425 +2
🚀 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.
|
The reverse proxy scenarios are real regressions that need addressed. |
Assisted-by: opencode:gpt-5.6-sol
… gaps
Addresses jdaugherty's 3 unresolved review findings, all confirmed
against the current code before fixing:
- Spring Security precedence (verified inverted doc claim): this filter
registers at GrailsFilters.LAST (order -110), before Spring Security's
chain (-100), and applied its defaults eagerly - so Spring Security's
header writers (which skip a header that's already present) never got
a chance to win, contrary to what the upgrade notes claimed. Fixed by
adding @ConditionalOnMissingClass("...HeaderWriterFilter") to
GrailsSecurityHeadersAutoConfiguration so the whole auto-configuration
backs off when Spring Security's header-writing infrastructure is on
the classpath, per jdaugherty's suggested option - Spring Security
already ships its own configurable header defaults. Added a
spring-security-web test-only dependency and a
FilteredClassLoader-based helper so the existing "Spring Security
absent" tests keep exercising that path explicitly, plus a new test
confirming the back-off.
- HSTS silently never sent behind a TLS-terminating reverse proxy
(request.isSecure() is false unless server.forward-headers-strategy
is configured): this isn't a code bug - request.isSecure() is the
correct check, and Spring Boot's forward-headers-strategy is the
standard way to make it proxy-aware - so documented the proxy case
and the mitigation in the security guide instead of changing behavior.
- Reverse-proxy header duplication (nginx add_header appends rather than
replaces, so the client can receive a header twice once the app also
sends it): also not something a code change here can generally detect
or fix, since the proxy operates entirely outside the request
pipeline. Documented the risk (with the concrete Chrome/CSP/
Referrer-Policy duplicate-header failure modes jdaugherty identified)
and the mitigation (disable the affected header(s) at the app and let
the proxy own them, or strip the app's copy at the proxy).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
✅ All tests passed ✅🏷️ Commit: b2f970d 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]