Skip to content

Commit f3c709e

Browse files
bpamiriclaude
andauthored
feat(middleware): add hsts off-switch to SecurityHeaders (#2174) (#2195)
Adds a boolean `hsts` constructor argument (default `true`) to `wheels.middleware.SecurityHeaders`. When set to `false`, the middleware skips emitting `Strict-Transport-Security` entirely, regardless of the resolved environment or explicit `strictTransportSecurity` value. This gives apps behind TLS-terminating proxies (load balancers, CDNs) that already emit HSTS a clean off-switch without having to fall back to `max-age=0` (which clears browser HSTS memory) or duplicating the proxy's header. Defaults to `true` to preserve existing behavior byte-for-byte. Closes #2174 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d011040 commit f3c709e

5 files changed

Lines changed: 47 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
4848
- Middleware pipeline: closure-based chain running at dispatch level before controller instantiation, route-scoped via `.scope(middleware=[...])` or global via `set(middleware=[...])` (#1924)
4949
- Rate limiting middleware with `wheels.middleware.RateLimiter` supporting fixed window, sliding window, and token bucket strategies with in-memory and database storage (#1931)
5050
- SecurityHeaders middleware emits Content-Security-Policy, HSTS, and Permissions-Policy headers (#2036)
51+
- `hsts` argument on `SecurityHeaders` middleware to suppress the `Strict-Transport-Security` header entirely, for apps behind TLS-terminating proxies that emit HSTS themselves (#2174)
5152
- Multi-tenant support with per-request datasource switching (#1951)
5253

5354
**Views**

vendor/wheels/middleware/SecurityHeaders.cfc

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" {
1717
* @referrerPolicy Referrer-Policy value.
1818
* @contentSecurityPolicy Content-Security-Policy value. Empty by default (opt-in) because a restrictive policy can break apps with inline scripts/styles.
1919
* @strictTransportSecurity Strict-Transport-Security value. Auto-defaults to `max-age=31536000; includeSubDomains` in production when not explicitly set.
20+
* @hsts Set to false to suppress the Strict-Transport-Security header entirely, regardless of environment or strictTransportSecurity value. Useful when a TLS-terminating proxy already emits HSTS.
2021
* @permissionsPolicy Permissions-Policy value. Empty by default (opt-in) because it is app-specific.
2122
* @environment Application environment (e.g. "production", "development"). When empty, falls back to application.$wheels.environment if available.
2223
*/
@@ -27,6 +28,7 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" {
2728
string referrerPolicy = "strict-origin-when-cross-origin",
2829
string contentSecurityPolicy = "",
2930
string strictTransportSecurity = "",
31+
boolean hsts = true,
3032
string permissionsPolicy = "",
3133
string environment = ""
3234
) {
@@ -44,10 +46,14 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" {
4446
}
4547
}
4648

47-
// Default HSTS in production when not explicitly configured
48-
local.hsts = arguments.strictTransportSecurity;
49-
if (!Len(local.hsts) && local.env == "production") {
50-
local.hsts = "max-age=31536000; includeSubDomains";
49+
// Default HSTS in production when not explicitly configured. When arguments.hsts is false,
50+
// skip entirely so the header is not emitted regardless of environment or explicit value.
51+
local.hsts = "";
52+
if (arguments.hsts) {
53+
local.hsts = arguments.strictTransportSecurity;
54+
if (!Len(local.hsts) && local.env == "production") {
55+
local.hsts = "max-age=31536000; includeSubDomains";
56+
}
5157
}
5258

5359
if (Len(arguments.frameOptions)) {

vendor/wheels/tests/specs/middleware/SecurityHeadersSpec.cfc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,30 @@ component extends="wheels.WheelsTest" {
130130
expect(local.mw.$headers()).notToHaveKey("Strict-Transport-Security");
131131
});
132132

133+
it("omits HSTS header when hsts=false even in production", function() {
134+
local.mw = new wheels.middleware.SecurityHeaders(
135+
environment = "production",
136+
hsts = false
137+
);
138+
expect(local.mw.$headers()).notToHaveKey("Strict-Transport-Security");
139+
});
140+
141+
it("omits HSTS header when hsts=false and explicit strictTransportSecurity provided", function() {
142+
local.mw = new wheels.middleware.SecurityHeaders(
143+
strictTransportSecurity = "max-age=86400",
144+
hsts = false
145+
);
146+
expect(local.mw.$headers()).notToHaveKey("Strict-Transport-Security");
147+
});
148+
149+
it("defaults hsts=true to preserve legacy production behavior", function() {
150+
local.mw = new wheels.middleware.SecurityHeaders(
151+
environment = "production"
152+
);
153+
expect(local.mw.$headers()).toHaveKey("Strict-Transport-Security");
154+
expect(local.mw.$headers()["Strict-Transport-Security"]).toBe("max-age=31536000; includeSubDomains");
155+
});
156+
133157
});
134158

135159
describe("Permissions-Policy", function() {

web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/security-hardening.mdx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Verified against `vendor/wheels/middleware/SecurityHeaders.cfc`:
4949
| `referrerPolicy` | `Referrer-Policy` | `strict-origin-when-cross-origin` | line 27, 63 |
5050
| `contentSecurityPolicy` | `Content-Security-Policy` | `""` (opt-in) | line 28, 65-67 |
5151
| `strictTransportSecurity` | `Strict-Transport-Security` | `""` — auto-resolves to `max-age=31536000; includeSubDomains` in production | line 29, 48-51, 68-70 |
52+
| `hsts` || `true` — set to `false` to suppress `Strict-Transport-Security` entirely | new |
5253
| `permissionsPolicy` | `Permissions-Policy` | `""` (opt-in) | line 30, 71-73 |
5354
| `environment` || `""` — falls back to `application.$wheels.environment` | line 31, 37-45 |
5455

@@ -91,8 +92,16 @@ new wheels.middleware.SecurityHeaders(
9192
)
9293
```
9394

94-
<Aside type="caution">
95-
**No HSTS off-switch yet.** `SecurityHeaders` has no boolean to disable HSTS entirely. Setting `strictTransportSecurity=""` keeps the production auto-default; setting `"max-age=0"` tells browsers to *clear* HSTS memory, which is worse. The only supported way to suppress the header today is to pass an explicit value that matches your proxy. Tracked in [#2174](https://github.com/wheels-dev/wheels/issues/2174).
95+
<Aside type="tip">
96+
**Suppressing HSTS entirely.** When your TLS-terminating proxy (load balancer, CDN, reverse proxy) already emits `Strict-Transport-Security`, pass `hsts=false` so the app does not emit a competing header:
97+
98+
```cfm title="config/settings.cfm"
99+
set(middleware = [
100+
new wheels.middleware.SecurityHeaders(hsts=false)
101+
]);
102+
```
103+
104+
`hsts=false` takes precedence over both `strictTransportSecurity` and the production auto-default. Leave `hsts=true` (the default) whenever the app owns the HSTS header.
96105
</Aside>
97106

98107
## CSRF protection

web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Related hardening: wildcard-plus-credentials combinations are now rejected (#205
6565

6666
**CHANGELOG:** `Breaking: HSTS header defaults on in production` (#2081).
6767

68-
The `SecurityHeaders` middleware (#2036) emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` by default in the `production` environment. If you terminate TLS outside the app and do not want HSTS emitted from here, there is currently no clean off-switch — see [#2174](https://github.com/wheels-dev/wheels/issues/2174). The workaround until that lands is to strip `Strict-Transport-Security` at your reverse proxy. `deployment/security-hardening.mdx` covers the configuration knobs that do exist today.
68+
The `SecurityHeaders` middleware (#2036) emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` by default in the `production` environment. To suppress HSTS emission from the app (e.g., when your load balancer already sets it), pass `hsts=false` to the middleware constructor (#2174). `deployment/security-hardening.mdx` covers the full set of configuration knobs.
6969

7070
### 3. CSRF key required in production; JWT algorithm validated
7171

0 commit comments

Comments
 (0)