Commits
Tarun Sukhani authored 8d73ffce007
feat: secure-by-default HTTP response headers (PF-5)
Adds a SecurityHeadersPolicy that applies a configurable set of default
security headers to every HTTP response — controller results, 404s, 500s,
and static assets — regardless of which transport (Netty or servlet)
handled the request. Headers are additive: anything the application set
on response.headers always wins.
Architecture
- play.server.SecurityHeadersPolicy: immutable, volatile-swapped policy
with two applyTo() overloads (Netty HttpHeaders, servlet
HttpServletResponse). Pre-builds the static-header map and HSTS value
in fromConfig() so the request hot path is a contains() check + map
iteration.
- play.plugins.SecurityHeadersPlugin (slot 10:): lifecycle-only — loads
from Play.configuration on onConfigurationRead() and installs the
policy; resets to DISABLED on onApplicationStop(). Hot-reload works
for free via ConfigurationChangeWatcherPlugin re-firing
onConfigurationRead.
- PlayHandler: policy applied at addToResponse (covers controller
results + the static raw branch + websocket fallthroughs), serve404,
serve500, and serveStatic's static-file branch — including the
catch-block error response. Each call site picks up request.secure
null-safely.
- ServletWrapper: matching coverage on the legacy servlet transport
(copyResponse, serveStatic non-raw branch, serve404, serve500), so
the two transports stay aligned the way PF-62 established for
cookies.
The plugin can be disabled by either http.headers.enabled=false or
removing the entry from play.plugins.
Defaults
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- Referrer-Policy: strict-origin-when-cross-origin
- X-XSS-Protection: 0 (defer to CSP per current guidance)
- Content-Security-Policy: default-src 'self'
- Strict-Transport-Security: max-age=31536000; includeSubDomains
(HTTPS responses only)
All headers are individually overridable / disableable via
http.headers.* keys; HSTS has its own sub-config.
Tests
- Unit (17): SecurityHeadersPolicyTest exercises both applyTo overloads
against fake Netty headers + Mockito-mocked HttpServletResponse — no
Http.Request plumbing needed, so the deprecated 14-arg createRequest
is avoided entirely.
- Lifecycle (5): SecurityHeadersPluginTest covers install / reload /
master-switch / play.plugins registration.
- E2E (4): SecurityHeadersFunctionalTest stands up the real Netty
server over HTTPS and asserts headers on the controller, 404, 500,
and static asset paths — proving the gap the original plugin-only
design left on error and static responses is closed.
Testapp fixtures
- TestController.boom() + GET /boom route give the integration test a
guaranteed 500-throwing endpoint.
- Local errors/{404,500}.html overrides in the testapp work around the
framework default templates referencing /public/stylesheets/play-tailwind.css
(a Nuxt-skel asset that plain Play apps do not ship). Surfaced by
the integration 404 test; should be filed for a proper fix in the
framework templates.
Docs
- README "Default security headers" section with the configuration
table and per-response override semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>