header_guard 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af8346262e02cbf08a1efb6b7de1ea08d55f8d262be2801421a8f848796b96e4
4
- data.tar.gz: f8a9d346ca8ba26ae507d99d1780a97be8499e9e5e537239de32fee4fb043ed8
3
+ metadata.gz: 9fde99ae71b2986c94287ef5b903ba52d00405019346f9c2f61a88c5c81dd24e
4
+ data.tar.gz: c7af2219fc4d05c19c2923184515921cc91f37e0cbbdb69ef65ae4d0b778c668
5
5
  SHA512:
6
- metadata.gz: b08e3c213d232cb27d04eb8714f71894b9dfd9b0543d02ed971d9171551ecde1d57b2b57a938019facf9c5d69f568096453c7aa20b1a347e3067f2734dcc20b8
7
- data.tar.gz: bb04592f757450600bae59a35b2572253642af6aa69af37e8c4e6724657b22d294a5aa6d859c39a2354262f42d13a9883be92a4b63d83d7b11321eb186578e7c
6
+ metadata.gz: 74bb808854cc17a68c1a48d7c424e7e177dd30dcbbe4ae9ecfe5f5fa446475377c443a70a57529bc3a9e5d37dddcb2d39f3008a6da75bf5ddff6f5d6ebf3ee43
7
+ data.tar.gz: 226cbb1dd847db813eb69c236c59591440f31906683f8e644f1d3dd076e908aeda2c551abae859befd86b8b5909cd05946fa9530c9b0a33dd908efc18616f5b5
data/CHANGELOG.md CHANGED
@@ -7,7 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- See `PLAN.md` for the remaining remediation work (P1–P5), targeted at 0.2.0.
10
+ See `PLAN.md` for the remaining work (P4–P5).
11
+
12
+ ## [0.3.0] - 2026-09-12
13
+
14
+ ### Added
15
+
16
+ - **Option validation.** Every option is checked at construction and raises `ArgumentError`
17
+ with a specific message. Previously an unrecognised Symbol key became a header name
18
+ verbatim, so `report_onlyy: true` emitted a junk `report_onlyy` header and silently
19
+ *enforced* a CSP the user meant only to report on. Now rejected: unknown Symbol options;
20
+ header names that are not valid HTTP tokens; `Content-Security-Policy` or its
21
+ `-Report-Only` variant given as a raw header (use the option); values that are not a
22
+ `String`; empty values; `report_only` / `html_only` values that are not `true` or `false`.
23
+
24
+ - **Control characters in header values are rejected.** A header value or CSP containing
25
+ CR, LF, NUL or any other control character raises at startup. A CR/LF in a value built
26
+ from configuration (a `report-uri` from an environment variable, say) would otherwise
27
+ let it inject further headers or split the response.
28
+
29
+ - **Removing a default header.** Set a header to `nil` (or `false`) and HeaderGuard stops
30
+ managing it: the default is not injected and any value the application sets itself
31
+ passes through untouched. Previously there was no way to opt out of a default; the README
32
+ suggested `""`, which emitted a malformed empty header.
33
+
34
+ - **Disabling the CSP.** `content_security_policy: false` stops HeaderGuard sending a CSP,
35
+ for applications that build one elsewhere (the Rails `content_security_policy` DSL, for
36
+ example) and want HeaderGuard for the other headers only. `content_security_policy: nil`
37
+ deliberately keeps the *default* policy, so an unset environment variable cannot silently
38
+ drop the CSP.
39
+
40
+ - **Per-path overrides.** `path_overrides: { matcher => options }` scopes a different
41
+ policy to particular routes — an identity provider's popup page that must keep
42
+ `window.opener`, an embeddable widget that other sites frame, a legacy page that still
43
+ needs inline scripts — without relaxing anything site-wide. A `Regexp` key is matched
44
+ against the request path; a `String` key must match exactly. First match wins. The value
45
+ is an options hash of the same shape as the top level (headers, `content_security_policy:`,
46
+ `report_only:`, `html_only:`), layered on top of the global configuration.
47
+ `Strict-Transport-Security` is refused inside an override: HSTS is host-scoped, not
48
+ per-document, so a weaker value on one path would apply to the whole site.
49
+
50
+ ### Upgrading from 0.2.x
51
+
52
+ - Configurations that were accepted but wrong now fail at startup. If HeaderGuard raises
53
+ `ArgumentError` on boot, the message names the offending option. The common cases: a
54
+ misspelled Symbol option, and `"Strict-Transport-Security" => ""` from the old README
55
+ advice — replace with `nil`.
56
+
57
+ ## [0.2.0] - 2026-09-12
58
+
59
+ Widens which responses receive security headers and hardens every default. Read
60
+ **Upgrading from 0.1.x** at the end of this entry before deploying.
61
+
62
+ ### Changed
63
+
64
+ - **Standard headers are now applied to every response.** `Strict-Transport-Security`,
65
+ `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy` were previously
66
+ injected only on 2xx responses with an HTML content type, which excluded the responses
67
+ that need them most: JSON bodies got no `nosniff`, and the HTTP→HTTPS redirect — the
68
+ response where HSTS matters — got no HSTS. They now go on every response regardless
69
+ of status code or content type.
70
+
71
+ - **The Content Security Policy is now applied to HTML responses of every status.**
72
+ Previously only 2xx HTML responses received a CSP, leaving 4xx/5xx error pages
73
+ unprotected. Error pages routinely reflect user input and are a classic XSS surface.
74
+ CSP remains restricted to HTML content types, as it governs documents only.
75
+
76
+ - `application/xhtml+xml` is now treated as HTML for CSP purposes alongside `text/html`.
77
+
78
+ - **`preload` removed from the default `Strict-Transport-Security`.** Preload is the
79
+ opt-in signal for the browser HSTS preload list, which hard-codes the apex domain and
80
+ every subdomain as HTTPS-only inside the browser itself; removal takes months. That is
81
+ a commitment to make explicitly, not one to acquire by adding a middleware. The default
82
+ is now `max-age=31536000; includeSubDomains`. Opt back in by overriding the header.
83
+
84
+ - **Default CSP tightened to a strict same-origin baseline.** `style-src` was
85
+ `'self' 'unsafe-inline' https:` and `font-src` was `'self' https: data:`. Both are now
86
+ `'self'`. `https:` as a source allows content from any HTTPS origin and `'unsafe-inline'`
87
+ allows arbitrary inline styles — either lets an attacker who can inject markup load or
88
+ run content of their choosing, which is a weak default for something billed as a secure
89
+ baseline. Applications that need inline styles should add `'unsafe-inline'` deliberately
90
+ via `content_security_policy:`.
91
+
92
+ - Default CSP directives are now separated by `"; "` rather than `";"` for readability.
93
+ Semantically identical.
94
+
95
+ ### Added
96
+
97
+ - `html_only: true` option, which restores the 0.1.x behaviour of injecting nothing
98
+ unless the response is a 2xx with an HTML content type. This is a migration aid for
99
+ applications that depended on the narrower scope, not a recommended configuration.
100
+
101
+ - **Four new default headers:**
102
+ - `Cross-Origin-Opener-Policy: same-origin-allow-popups` — isolates the browsing
103
+ context from cross-origin openers, mitigating XS-Leaks and Spectre-class attacks,
104
+ while still allowing popups the site itself opens (OAuth/OIDC providers in popup
105
+ mode) to talk back via `window.opener`. Chosen over the stricter `same-origin`
106
+ because the gem targets SSO applications, where popup-based client SDKs are common;
107
+ the only protection given up is against windows the site's own code chose to open.
108
+ - `Cross-Origin-Resource-Policy: same-origin` — stops other origins embedding this
109
+ site's resources via no-cors requests.
110
+ - `X-Permitted-Cross-Domain-Policies: none` — forbids Flash/Acrobat cross-domain
111
+ policy files.
112
+ - `Permissions-Policy` — denies `accelerometer`, `camera`, `geolocation`, `gyroscope`,
113
+ `magnetometer`, `microphone`, `payment` and `usb` unless explicitly enabled.
114
+
115
+ ### Removed
116
+
117
+ - `block-all-mixed-content` from the default CSP. The directive is deprecated and was
118
+ removed from CSP Level 3; `upgrade-insecure-requests`, which remains, covers it.
119
+
120
+ ### Upgrading from 0.1.x
121
+
122
+ Read this section before deploying — 0.2.0 tightens several defaults and some
123
+ applications will need to opt back into behaviour they relied on.
124
+
125
+ - **Standard headers now land on every response.** If your application sets its own
126
+ value for one of them on a non-HTML response (for example a different
127
+ `X-Frame-Options` on an API endpoint), HeaderGuard now overwrites it there too, as it
128
+ always has on HTML. Pass the desired value as a custom header option, or use
129
+ `html_only: true` while you migrate.
130
+ - **Inline styles are now blocked by the default CSP.** If your pages use inline
131
+ `<style>` or `style=""` attributes (CSS-in-JS libraries commonly do), pass a
132
+ `content_security_policy:` that adds `'unsafe-inline'` to `style-src`, or better, move
133
+ to nonces.
134
+ - **Third-party fonts and stylesheets are now blocked by the default CSP.** Add the
135
+ specific origins you use (e.g. `https://fonts.googleapis.com`) to `style-src` and
136
+ `font-src` rather than reinstating `https:`.
137
+ - **Identity providers serving popup-based flows need to override
138
+ `Cross-Origin-Opener-Policy`.** The default `same-origin-allow-popups` keeps flows
139
+ where *your* site opens the popup working, but if your site *is* the popup (you are
140
+ the identity provider), the page the client opens must keep `window.opener`, which
141
+ requires `unsafe-none`. Redirect-based flows are unaffected either way.
142
+ - **Assets embedded by other sites will be blocked by `Cross-Origin-Resource-Policy`.**
143
+ If your app serves images, scripts or fonts meant to load on other origins, override
144
+ with `cross-origin`.
145
+ - **Features denied by `Permissions-Policy`** (camera, microphone, geolocation, payment,
146
+ and so on) must be re-enabled explicitly if your app uses them.
147
+ - **HSTS preload is no longer set.** If you had already submitted your domain to the
148
+ preload list, add `preload` back via a custom header — otherwise the list's periodic
149
+ checks will flag the domain for removal.
11
150
 
12
151
  ## [0.1.2] - 2026-09-08
13
152
 
@@ -64,7 +203,9 @@ See `PLAN.md` for the remaining remediation work (P1–P5), targeted at 0.2.0.
64
203
  `X-Frame-Options`, `Referrer-Policy` and a configurable Content Security Policy,
65
204
  with `report_only` support.
66
205
 
67
- [Unreleased]: https://github.com/danielefrisanco/headerguard/compare/v0.1.2...HEAD
206
+ [Unreleased]: https://github.com/danielefrisanco/headerguard/compare/v0.3.0...HEAD
207
+ [0.3.0]: https://github.com/danielefrisanco/headerguard/compare/v0.2.0...v0.3.0
208
+ [0.2.0]: https://github.com/danielefrisanco/headerguard/compare/v0.1.2...v0.2.0
68
209
  [0.1.2]: https://github.com/danielefrisanco/headerguard/compare/v0.1.1...v0.1.2
69
210
  [0.1.1]: https://github.com/danielefrisanco/headerguard/compare/v0.1.0...v0.1.1
70
211
  [0.1.0]: https://github.com/danielefrisanco/headerguard/releases/tag/v0.1.0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- header_guard (0.1.2)
4
+ header_guard (0.3.0)
5
5
  rack (~> 3.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -60,12 +60,14 @@ If you only want to relax one or two headers (like HSTS) but keep the others, yo
60
60
  header_options = {}
61
61
 
62
62
  if Rails.env.development? || Rails.env.test?
63
- # 1. Disable Strict-Transport-Security for local HTTP development
64
- header_options["Strict-Transport-Security"] = ""
63
+ # 1. Don't send Strict-Transport-Security for local HTTP development.
64
+ # nil means "HeaderGuard does not manage this header". (An empty string
65
+ # is rejected: it would send a malformed header.)
66
+ header_options["Strict-Transport-Security"] = nil
65
67
 
66
- # 2. Relax CSP to allow development tools that rely on 'unsafe-inline' scripts/styles
67
- # NOTE: The HeaderGuard default CSP uses 'script-src "self"'. This adds the required dev overrides.
68
- dev_csp = "script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
68
+ # 2. Relax CSP to allow development tools that rely on 'unsafe-inline' scripts/styles.
69
+ # This replaces the whole default policy, so include every directive you still want.
70
+ dev_csp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'"
69
71
  header_options[:content_security_policy] = dev_csp
70
72
  end
71
73
 
@@ -82,14 +84,18 @@ When integrating `HeaderGuard` into your project, you can pass an options hash t
82
84
 
83
85
  #### 1\. Overriding Standard Headers
84
86
 
85
- Any key/value pair passed to the middleware that matches a standard header will override the default value.
87
+ Any key/value pair passed to the middleware that matches a standard header will override the default value. Header names are matched case-insensitively.
86
88
 
87
- | Header | Default Value | Purpose |
88
- | ----- | ----- | ----- |
89
- | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | Enforces HTTPS usage. |
90
- | `X-Content-Type-Options` | `nosniff` | Prevents browser MIME-sniffing. |
91
- | `X-Frame-Options` | `DENY` | Prevents clickjacking (set to SAMEORIGIN to allow framing on the same site). |
92
- | `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer information sent with requests. |
89
+ | Header | Default Value | Purpose |
90
+ | ----- | ----- | ----- |
91
+ | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Enforces HTTPS usage. |
92
+ | `X-Content-Type-Options` | `nosniff` | Prevents browser MIME-sniffing. |
93
+ | `X-Frame-Options` | `DENY` | Prevents clickjacking (set to `SAMEORIGIN` to allow framing on the same site). |
94
+ | `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer information sent with requests. |
95
+ | `Cross-Origin-Opener-Policy` | `same-origin-allow-popups` | Isolates the browsing context from cross-origin openers (XS-Leaks, Spectre) while allowing popups you open. |
96
+ | `Cross-Origin-Resource-Policy` | `same-origin` | Stops other origins embedding your resources via no-cors requests. |
97
+ | `X-Permitted-Cross-Domain-Policies` | `none` | Forbids Flash/Acrobat cross-domain policy files. |
98
+ | `Permissions-Policy` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` | Denies sensitive device features unless enabled. |
93
99
 
94
100
  **Example: Overriding X-Frame-Options and Referrer-Policy:**
95
101
  ```ruby
@@ -98,14 +104,55 @@ config.middleware.use HeaderGuard::Middleware,
98
104
  "X-Frame-Options" => "SAMEORIGIN",
99
105
  "Referrer-Policy" => "no-referrer"
100
106
  ```
107
+
108
+ **Opting into HSTS preload.** The default deliberately omits `preload`. It is the signal for the [browser HSTS preload list](https://hstspreload.org/), which hard-codes your apex domain *and every subdomain* as HTTPS-only inside the browser, and removal takes months. Add it only once you've confirmed every subdomain serves HTTPS:
109
+
110
+ ```ruby
111
+ config.middleware.use HeaderGuard::Middleware,
112
+ "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload"
113
+ ```
114
+
115
+ **`Cross-Origin-Opener-Policy` and popup-based auth.** The default `same-origin-allow-popups` means no cross-origin page can open your site and keep a handle on it, while popups *your* site opens — an OAuth/OIDC provider in popup mode — can still talk back via `window.opener`. Redirect-based flows are unaffected either way.
116
+
117
+ Two situations need a different value:
118
+
119
+ ```ruby
120
+ # Your site *is* the popup (you are the identity provider): the page the client
121
+ # opens must keep window.opener, which requires disabling isolation on it.
122
+ config.middleware.use HeaderGuard::Middleware, "Cross-Origin-Opener-Policy" => "unsafe-none"
123
+
124
+ # You open no popups and want the strictest isolation available (also the
125
+ # value required, together with COEP, for cross-origin isolated features such
126
+ # as SharedArrayBuffer):
127
+ config.middleware.use HeaderGuard::Middleware, "Cross-Origin-Opener-Policy" => "same-origin"
128
+ ```
129
+
130
+ **Assets embedded by other sites and `Cross-Origin-Resource-Policy`.** `same-origin` prevents other origins from loading your images, scripts or fonts. If your app serves assets meant to be embedded elsewhere, set it to `cross-origin`.
101
131
  #### 2\. Custom Content Security Policy (CSP)
102
132
 
103
- You can define a custom CSP string to replace the secure default provided by HeaderGuard.
133
+ The default CSP is a strict same-origin baseline:
134
+
135
+ ```
136
+ default-src 'self'; base-uri 'self'; font-src 'self'; form-action 'self';
137
+ frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self';
138
+ upgrade-insecure-requests
139
+ ```
140
+
141
+ Resources may load only from your own origin; plugins, `<base>` tags and object embeds are blocked; inline scripts and styles are not permitted. Most real applications will need to extend this. You can define a custom CSP string to replace the default:
104
142
 
105
143
  ```ruby
106
- custom_csp = "default-src 'self'; script-src 'self' [https://trusted.cdn.com](https://trusted.cdn.com);"
144
+ custom_csp = "default-src 'self'; script-src 'self' https://trusted.cdn.com;"
107
145
  use HeaderGuard::Middleware, content_security_policy: custom_csp
108
146
 
147
+ ```
148
+
149
+ When extending the policy, add the specific origins you need rather than broad sources. `https:` as a source allows content from *any* HTTPS origin, and `'unsafe-inline'` allows any inline style or script — both let an attacker who can inject markup load or run content of their choosing. If you must allow inline styles (many CSS-in-JS libraries need it), do so knowingly:
150
+
151
+ ```ruby
152
+ # Allowing inline styles, explicitly.
153
+ use HeaderGuard::Middleware,
154
+ content_security_policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'"
155
+
109
156
  ```
110
157
  #### 3\. Report-Only Mode
111
158
 
@@ -116,18 +163,88 @@ To test a new CSP without enforcing it, set the report\_only option to true. Thi
116
163
  config.middleware.use HeaderGuard::Middleware, report_only: true
117
164
 
118
165
  ```
166
+ #### 4\. Restricting to HTML Responses (legacy behaviour)
167
+
168
+ By default HeaderGuard applies its standard headers to **every** response and the CSP to every **HTML** response, whatever the status code (see *How It Works* below). Versions before 0.2.0 injected nothing unless the response was a 2xx with an HTML content type. If you depend on that narrower behaviour, set `html_only`:
169
+
170
+ ```ruby
171
+ # 0.1.x behaviour: inject only on 2xx text/html responses.
172
+ config.middleware.use HeaderGuard::Middleware, html_only: true
173
+
174
+ ```
175
+ This is a migration aid, not a recommended configuration: it leaves JSON responses without `X-Content-Type-Options`, redirects without HSTS, and error pages without a CSP.
176
+
177
+ #### 5\. Removing a Default Header
178
+
179
+ Set a header to `nil` (or `false`) and HeaderGuard stops managing it: the default is not injected, and any value your application sets itself passes through untouched.
180
+
181
+ ```ruby
182
+ # Don't send X-Frame-Options; the app sets its own where it needs one.
183
+ config.middleware.use HeaderGuard::Middleware, "X-Frame-Options" => nil
184
+
185
+ ```
186
+
187
+ To stop HeaderGuard sending a CSP at all — for example because your application builds one elsewhere, such as with the Rails `content_security_policy` DSL — pass `false`:
188
+
189
+ ```ruby
190
+ # HeaderGuard manages every header except the CSP.
191
+ config.middleware.use HeaderGuard::Middleware, content_security_policy: false
192
+
193
+ ```
194
+
195
+ Note that `content_security_policy: nil` keeps the **default** policy rather than disabling it, so an unset environment variable (`content_security_policy: ENV["CSP"]`) cannot silently drop your CSP. Only an explicit `false` turns it off.
196
+
197
+ #### 6\. Per-Path Overrides
198
+
199
+ Some routes legitimately need a different policy from the rest of the site: an OAuth callback page that must keep `window.opener`, a widget that other sites embed in a frame, a legacy admin page that still needs inline scripts. Rather than relaxing a header site-wide for the sake of one route, scope the change to that route:
200
+
201
+ ```ruby
202
+ config.middleware.use HeaderGuard::Middleware,
203
+ path_overrides: {
204
+ # You are the identity provider: the popup page the client opens must keep window.opener.
205
+ %r{\A/oauth/authorize\z} => { "Cross-Origin-Opener-Policy" => "unsafe-none" },
206
+
207
+ # An embeddable widget: framed by other sites, its assets loaded cross-origin.
208
+ %r{\A/embed/} => {
209
+ "X-Frame-Options" => nil,
210
+ "Cross-Origin-Resource-Policy" => "cross-origin",
211
+ content_security_policy: "default-src 'self'; frame-ancestors *"
212
+ },
213
+
214
+ # Trial a stricter policy on one page before rolling it out.
215
+ "/checkout" => { content_security_policy: "default-src 'none'; script-src 'self'", report_only: true }
216
+ }
217
+
218
+ ```
219
+
220
+ - A **`Regexp`** key is matched against the request path; a **`String`** key must match the path exactly (it is not a prefix — use a `Regexp` for prefixes). The first matching entry wins.
221
+ - The value is an options hash with the same shape as the top level: header overrides, `content_security_policy:`, `report_only:`, `html_only:`. It is layered **on top of** your global configuration, so a path inherits everything it doesn't mention.
222
+ - **`Strict-Transport-Security` cannot be overridden per path** and HeaderGuard will refuse to start if you try. HSTS is host-scoped, not per-document: a weaker value sent on one path would update the browser's policy for the entire site.
223
+
224
+ Anchor your patterns (`\A`, `\z`). `%r{/auth}` also matches `/authors`.
225
+
226
+ #### 7\. Validation
227
+
228
+ Every option is checked when the middleware is constructed, and HeaderGuard raises `ArgumentError` with a specific message rather than starting with a weakened policy:
229
+
230
+ - an unrecognised Symbol option (`reprot_only: true` used to emit a junk header and silently *enforce* the CSP it was meant to only report on);
231
+ - a header name that isn't a valid HTTP token, or that names the CSP (use `content_security_policy:` instead);
232
+ - a header value or CSP that isn't a `String`, is empty, or contains a control character — a CR/LF would let a value injected from configuration split the response;
233
+ - `report_only` / `html_only` values that aren't `true` or `false`;
234
+ - `Strict-Transport-Security` inside a path override.
119
235
 
120
236
  How It Works
121
237
  ------------
122
238
 
123
- HeaderGuard hooks into the Rack request lifecycle and performs the following actions on responses with a 2xx status code and a Content-Type of text/html:
239
+ HeaderGuard hooks into the Rack request lifecycle and, on every response passing through it:
124
240
 
125
- 1. **Header Merging:** It takes the default security headers and merges them with any custom headers supplied during initialization, ensuring user configuration takes precedence.
241
+ 1. **Header Merging:** At startup it validates your options, then merges any custom headers over the defaults, ensuring user configuration takes precedence. Per-path overrides are layered over that once more. On each request it picks the policy for the request path.
126
242
 
127
- 2. **Injection:** It injects the final set of standard security headers.
243
+ 2. **Standard Header Injection:** It injects every header in the table above (HSTS, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, the cross-origin isolation headers and `Permissions-Policy`) on **every** response, regardless of status code or content type. HSTS matters most on the HTTP→HTTPS redirect, and `nosniff` exists precisely to protect non-HTML bodies such as JSON.
128
244
 
129
- 3. **CSP Injection:** It injects the configured Content Security Policy, using either the standard enforcement header or the Report-Only header.
245
+ 3. **CSP Injection:** It injects the configured Content Security Policy using either the standard enforcement header or the Report-Only header — on every response whose `Content-Type` is `text/html` or `application/xhtml+xml`, **including error pages**. Error pages routinely reflect user input and are a classic XSS surface, so they need a policy at least as much as a 200 does. Non-HTML responses do not receive a CSP, as it governs documents only.
130
246
 
247
+ Header names are handled case-insensitively and always written in lowercase, as the Rack 3 SPEC requires, so both Rack 2 and Rack 3 style applications are supported.
131
248
 
132
249
  Development
133
250
  -----------
@@ -7,65 +7,267 @@ module HeaderGuard
7
7
  # requires response header keys to be lowercase, while Rack 2 applications
8
8
  # conventionally use capitalized keys, so this middleware reads incoming
9
9
  # headers case-insensitively and always writes lowercase keys.
10
+ #
11
+ # Which responses receive which headers:
12
+ #
13
+ # * The standard headers (HSTS, X-Content-Type-Options, X-Frame-Options,
14
+ # Referrer-Policy, ...) are applied to every response, regardless of status
15
+ # or content type. HSTS matters most on the HTTP->HTTPS redirect, and
16
+ # nosniff exists precisely to protect non-HTML bodies such as JSON.
17
+ # * The Content Security Policy governs a document, so it is applied only to
18
+ # HTML responses -- but on every status. Error pages reflect user input and
19
+ # are a classic XSS surface, so they need a policy at least as much as a
20
+ # 200 does.
21
+ #
22
+ # Options are validated at construction and raise ArgumentError on anything
23
+ # unrecognised or malformed, so a typo cannot silently weaken the policy.
10
24
  class Middleware
25
+ # Content types treated as HTML documents for the purpose of applying CSP.
26
+ HTML_CONTENT_TYPES = ["text/html", "application/xhtml+xml"].freeze
27
+
28
+ # Symbol keys with special meaning. Every other key must be a String naming
29
+ # a header; any other Symbol is a typo and is rejected.
30
+ OPTION_KEYS = %i[content_security_policy report_only html_only path_overrides].freeze
31
+
32
+ # Headers set through their own option rather than as a raw header, so the
33
+ # two mechanisms cannot silently fight over the same response header.
34
+ RESERVED_HEADERS = ["content-security-policy", "content-security-policy-report-only"].freeze
35
+
36
+ # Headers whose effect is host-wide rather than per-document. Sending a
37
+ # weaker value on one path would weaken it for the whole site, so they may
38
+ # not appear in path_overrides.
39
+ HOST_SCOPED_HEADERS = ["strict-transport-security"].freeze
40
+
41
+ # The RFC 7230 token alphabet: the only characters legal in a header name.
42
+ HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
43
+
44
+ # Any control character in a header value is rejected. CR and LF in
45
+ # particular would let a value inject further headers or a whole second
46
+ # response (response splitting).
47
+ CONTROL_CHARS = /[\x00-\x1F\x7F]/.freeze
48
+
49
+ CSP_HEADER = "content-security-policy"
50
+ CSP_REPORT_ONLY_HEADER = "content-security-policy-report-only"
51
+
52
+ # Fully resolved configuration for one scope: the whole site, or one path.
53
+ # `csp_value` is nil when CSP is disabled for the scope.
54
+ Policy = Struct.new(:headers, :csp_key, :csp_value, :html_only, keyword_init: true)
55
+
11
56
  # Initializes the middleware. It merges user-defined options over defaults.
12
57
  #
13
58
  # @param app [Object] The next application in the Rack stack.
14
59
  # @param options [Hash] Configuration options for headers and CSP.
60
+ # @raise [ArgumentError] on any unknown option, malformed header name or
61
+ # value, or a path override that would weaken a host-scoped header.
15
62
  def initialize(app, options = {})
16
63
  @app = app
17
64
 
18
- # Use a copy of options for configuration extraction
19
- config = options.dup
20
-
21
- # Extract special configuration settings
22
- custom_csp = config.delete(:content_security_policy)
23
- @report_only = config.delete(:report_only) || false
65
+ raise ArgumentError, "HeaderGuard options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
24
66
 
25
- # 1. Start with DEFAULT_HEADERS (from header_guard.rb)
26
- # 2. Merge remaining options (which are custom headers) over the defaults,
27
- # so the user's header values take precedence.
28
- # 3. Normalize every key to lowercase, so a custom "X-Frame-Options"
29
- # overrides the default rather than being emitted alongside it.
30
- @headers = DEFAULT_HEADERS.merge(config).each_with_object({}) do |(key, value), normalized|
31
- normalized[normalize_key(key)] = value
32
- end.freeze
67
+ defaults = Policy.new(
68
+ headers: normalize_keys(DEFAULT_HEADERS),
69
+ csp_key: CSP_HEADER,
70
+ csp_value: DEFAULT_CSP,
71
+ html_only: false
72
+ )
33
73
 
34
- # Set the final CSP value and the header key based on report_only setting
35
- @csp_value = custom_csp || DEFAULT_CSP
36
- @csp_header_key = @report_only ? "content-security-policy-report-only" : "content-security-policy"
74
+ @policy = build_policy(options, defaults, path: nil)
75
+ @path_policies = build_path_policies(options[:path_overrides])
37
76
  end
38
77
 
39
78
  # The Rack application call method.
40
79
  def call(env)
41
80
  status, headers, body = @app.call(env)
42
81
 
43
- # Only inject headers on successful (2xx) responses with HTML content.
44
- # Exclude redirects, errors, and non-HTML assets (like JSON or images).
45
- if (200..299).include?(status) && html?(headers)
46
- # Inject the standard headers
47
- @headers.each do |key, value|
82
+ policy = policy_for(env["PATH_INFO"])
83
+ html = html?(headers)
84
+
85
+ if apply_standard_headers?(policy, status, html)
86
+ policy.headers.each do |key, value|
48
87
  # We use assignment here, not `||=`, to ensure the middleware
49
88
  # overwrites any headers set by the application before it,
50
89
  # adhering to the strong security posture.
51
90
  assign(headers, key, value)
52
91
  end
53
-
54
- # Inject the configured CSP header
55
- assign(headers, @csp_header_key, @csp_value)
56
92
  end
57
93
 
94
+ assign(headers, policy.csp_key, policy.csp_value) if policy.csp_value && apply_csp?(policy, status, html)
95
+
58
96
  [status, headers, body]
59
97
  end
60
98
 
61
99
  private
62
100
 
101
+ # ------------------------------------------------------------------
102
+ # Configuration
103
+ # ------------------------------------------------------------------
104
+
105
+ # Layers one options Hash over a base Policy. Used once for the global
106
+ # options over the defaults, and once per path override over the global
107
+ # policy, so an override inherits everything it does not mention.
108
+ def build_policy(options, base, path:)
109
+ headers = base.headers.dup
110
+ csp_value = base.csp_value
111
+ report_only = base.csp_key == CSP_REPORT_ONLY_HEADER
112
+ html_only = base.html_only
113
+
114
+ options.each do |key, value|
115
+ case key
116
+ when :content_security_policy
117
+ csp_value = resolve_csp(value, csp_value)
118
+ when :report_only
119
+ report_only = resolve_flag(key, value, report_only)
120
+ when :html_only
121
+ html_only = resolve_flag(key, value, html_only)
122
+ when :path_overrides
123
+ raise ArgumentError, "path_overrides cannot be nested inside the override for #{path.inspect}" if path
124
+ when String
125
+ name = validate_header_name(key, path)
126
+ if value.nil? || value == false
127
+ headers.delete(name)
128
+ else
129
+ headers[name] = validate_header_value(key, value)
130
+ end
131
+ else
132
+ raise ArgumentError,
133
+ "unknown HeaderGuard option #{key.inspect}. Recognised options are " \
134
+ "#{OPTION_KEYS.map(&:inspect).join(', ')}; custom headers must be given as String keys."
135
+ end
136
+ end
137
+
138
+ Policy.new(
139
+ headers: headers.freeze,
140
+ csp_key: report_only ? CSP_REPORT_ONLY_HEADER : CSP_HEADER,
141
+ csp_value: csp_value,
142
+ html_only: html_only
143
+ )
144
+ end
145
+
146
+ def build_path_policies(overrides)
147
+ return [].freeze if overrides.nil?
148
+
149
+ unless overrides.is_a?(Hash)
150
+ raise ArgumentError, "path_overrides must be a Hash of path matcher => options, got #{overrides.class}"
151
+ end
152
+
153
+ overrides.map do |matcher, options|
154
+ unless matcher.is_a?(String) || matcher.is_a?(Regexp)
155
+ raise ArgumentError, "path_overrides keys must be a String (exact path) or a Regexp, got #{matcher.inspect}"
156
+ end
157
+ unless options.is_a?(Hash)
158
+ raise ArgumentError, "path_overrides[#{matcher.inspect}] must be an options Hash, got #{options.class}"
159
+ end
160
+
161
+ [matcher, build_policy(options, @policy, path: matcher)]
162
+ end.freeze
163
+ end
164
+
165
+ # nil keeps the inherited value (so an unset ENV var cannot silently
166
+ # disable CSP); false disables CSP for the scope; a String replaces it.
167
+ def resolve_csp(value, current)
168
+ case value
169
+ when nil then current
170
+ when false then nil
171
+ when String then validate_header_value(:content_security_policy, value)
172
+ else
173
+ raise ArgumentError, "content_security_policy must be a String, false, or nil, got #{value.inspect}"
174
+ end
175
+ end
176
+
177
+ def resolve_flag(key, value, current)
178
+ return current if value.nil?
179
+ return value if value == true || value == false
180
+
181
+ raise ArgumentError, "#{key} must be true or false, got #{value.inspect}"
182
+ end
183
+
184
+ def validate_header_name(key, path)
185
+ raise ArgumentError, "#{key.inspect} is not a valid HTTP header name" unless key.match?(HEADER_NAME)
186
+
187
+ name = key.downcase
188
+
189
+ if RESERVED_HEADERS.include?(name)
190
+ raise ArgumentError,
191
+ "#{key} cannot be set as a raw header; use the content_security_policy: and report_only: options"
192
+ end
193
+
194
+ if path && HOST_SCOPED_HEADERS.include?(name)
195
+ raise ArgumentError,
196
+ "#{key} cannot be overridden for #{path.inspect}: it is host-scoped, not per-document, " \
197
+ "so a value sent on one path would apply to the whole site"
198
+ end
199
+
200
+ name
201
+ end
202
+
203
+ def validate_header_value(key, value)
204
+ unless value.is_a?(String)
205
+ raise ArgumentError, "value for #{key.inspect} must be a String, got #{value.inspect}"
206
+ end
207
+ if value.empty?
208
+ raise ArgumentError, "value for #{key.inspect} is empty; pass nil to remove the header instead"
209
+ end
210
+ if value.match?(CONTROL_CHARS)
211
+ raise ArgumentError,
212
+ "value for #{key.inspect} contains a control character (CR, LF, ...), which would allow " \
213
+ "header injection or response splitting"
214
+ end
215
+
216
+ value
217
+ end
218
+
219
+ def normalize_keys(headers)
220
+ headers.each_with_object({}) do |(key, value), normalized|
221
+ normalized[normalize_key(key)] = value
222
+ end
223
+ end
224
+
63
225
  def normalize_key(key)
64
226
  key.to_s.downcase
65
227
  end
66
228
 
229
+ # ------------------------------------------------------------------
230
+ # Per-request
231
+ # ------------------------------------------------------------------
232
+
233
+ # First matching override wins; none matching means the global policy.
234
+ def policy_for(path)
235
+ path = path.to_s
236
+
237
+ @path_policies.each do |matcher, policy|
238
+ matched = matcher.is_a?(Regexp) ? matcher.match?(path) : matcher == path
239
+ return policy if matched
240
+ end
241
+
242
+ @policy
243
+ end
244
+
245
+ # Standard headers go on every response. In html_only mode they are
246
+ # restricted to 2xx HTML, as in 0.1.x.
247
+ def apply_standard_headers?(policy, status, html)
248
+ return true unless policy.html_only
249
+
250
+ success?(status) && html
251
+ end
252
+
253
+ # CSP goes on every HTML response regardless of status. In html_only mode
254
+ # it is restricted to 2xx HTML, as in 0.1.x.
255
+ def apply_csp?(policy, status, html)
256
+ return false unless html
257
+ return true unless policy.html_only
258
+
259
+ success?(status)
260
+ end
261
+
262
+ def success?(status)
263
+ (200..299).cover?(status)
264
+ end
265
+
67
266
  def html?(headers)
68
- fetch_header(headers, "content-type")&.include?("text/html")
267
+ content_type = fetch_header(headers, "content-type")
268
+ return false unless content_type
269
+
270
+ HTML_CONTENT_TYPES.any? { |type| content_type.include?(type) }
69
271
  end
70
272
 
71
273
  # Rack 3 responses key headers in lowercase; Rack 2 applications typically
@@ -2,5 +2,5 @@
2
2
 
3
3
  module HeaderGuard
4
4
  # The current version of the HeaderGuard gem.
5
- VERSION = "0.1.2"
5
+ VERSION = "0.3.0"
6
6
  end
data/lib/header_guard.rb CHANGED
@@ -4,38 +4,71 @@ require_relative "header_guard/version"
4
4
  require_relative "header_guard/middleware"
5
5
 
6
6
  module HeaderGuard
7
- # Standard security headers applied by default.
7
+ # Standard security headers applied to every response by default.
8
8
  DEFAULT_HEADERS = {
9
- # Strictly enforce HTTPS, preventing protocol downgrade attacks.
10
- # 1 year (31536000 seconds) max-age is standard best practice.
11
- "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload",
12
- # Prevent the browser from trying to guess the content type,
13
- # which can lead to XSS attacks if it misinterprets a file as a script.
14
- "X-Content-Type-Options" => "nosniff",
15
- # Tells the browser which referrer information to include with requests.
16
- # origin-when-cross-origin is a good balance of security and functionality.
17
- "X-Frame-Options" => "DENY",
18
- # Tells the browser which referrer information to include with requests.
19
- # origin-when-cross-origin is a good balance of security and functionality.
20
- "Referrer-Policy" => "strict-origin-when-cross-origin"
9
+ # Strictly enforce HTTPS for one year, including subdomains, preventing
10
+ # protocol downgrade attacks.
11
+ #
12
+ # `preload` is deliberately omitted. It is the opt-in signal for the browser
13
+ # HSTS preload list, which hard-codes the apex domain and every subdomain as
14
+ # HTTPS-only inside the browser itself, and removal from the list takes
15
+ # months. That is a commitment to make explicitly, not one to acquire by
16
+ # adding a middleware. Opt in with:
17
+ # "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload"
18
+ "Strict-Transport-Security" => "max-age=31536000; includeSubDomains",
19
+ # Prevent the browser from guessing the content type, which can lead to XSS
20
+ # if it misinterprets a file as a script.
21
+ "X-Content-Type-Options" => "nosniff",
22
+ # Forbid rendering this site inside a frame, preventing clickjacking. The
23
+ # legacy counterpart of the CSP `frame-ancestors` directive below.
24
+ "X-Frame-Options" => "DENY",
25
+ # Send the full referrer only on same-origin requests, and just the origin
26
+ # cross-origin; a good balance of security and functionality.
27
+ "Referrer-Policy" => "strict-origin-when-cross-origin",
28
+ # Put this site in its own browsing context group, so a cross-origin window
29
+ # that opens it cannot hold a reference to it. Mitigates XS-Leaks and
30
+ # Spectre-class attacks.
31
+ #
32
+ # "same-origin-allow-popups" rather than the stricter "same-origin": it
33
+ # keeps the protection that matters (nobody can open *this* site and keep a
34
+ # handle on it) while still letting popups this site opens -- an OAuth/OIDC
35
+ # provider in popup mode, say -- talk back via `window.opener`. The only
36
+ # thing given up is isolation from windows this site's own code chose to
37
+ # open, and `noopener` covers that where it matters.
38
+ #
39
+ # If this site *is* the popup (you are the identity provider), the page the
40
+ # client opens needs "unsafe-none". Redirect-based flows are unaffected.
41
+ "Cross-Origin-Opener-Policy" => "same-origin-allow-popups",
42
+ # Prevent other origins from embedding this site's resources (images,
43
+ # scripts, fonts) via no-cors requests. Override with "cross-origin" on
44
+ # assets that are meant to be embedded elsewhere.
45
+ "Cross-Origin-Resource-Policy" => "same-origin",
46
+ # Forbid Adobe Flash / Acrobat cross-domain policy files.
47
+ "X-Permitted-Cross-Domain-Policies" => "none",
48
+ # Deny access to sensitive device features unless explicitly enabled.
49
+ "Permissions-Policy" => "accelerometer=(), camera=(), geolocation=(), gyroscope=(), " \
50
+ "magnetometer=(), microphone=(), payment=(), usb=()"
21
51
  }.freeze
22
52
 
23
- # Default Content Security Policy. This is a secure baseline.
24
- # This serves as a strong baseline, allowing resources only from the same origin ('self'),
25
- # and explicitly blocking all plugins, base tags, and object embeds.
53
+ # Default Content Security Policy: a strict same-origin baseline.
26
54
  #
27
- # Note: A real application will likely need to customize this heavily
28
- # to allow CDNs, analytics scripts, etc.
29
- DEFAULT_CSP = (
30
- "default-src 'self';" \
31
- "base-uri 'self';" \
32
- "font-src 'self' https: data:;" \
33
- "form-action 'self';" \
34
- "frame-ancestors 'none';" \
35
- "object-src 'none';" \
36
- "script-src 'self';" \
37
- "style-src 'self' 'unsafe-inline' https:;" \
38
- "upgrade-insecure-requests;" \
39
- "block-all-mixed-content"
40
- ).freeze
55
+ # Resources may load only from this origin; plugins, <base> tags and object
56
+ # embeds are blocked outright; inline scripts and styles are not permitted.
57
+ #
58
+ # A real application will need to extend this -- to allow a CDN, an analytics
59
+ # script, inline styles -- but should do so by adding the specific origins or
60
+ # nonces it needs, not by reintroducing broad sources such as `https:` or
61
+ # `'unsafe-inline'`. Both let an attacker who can inject markup load or run
62
+ # content from an origin of their choosing.
63
+ DEFAULT_CSP = [
64
+ "default-src 'self'",
65
+ "base-uri 'self'",
66
+ "font-src 'self'",
67
+ "form-action 'self'",
68
+ "frame-ancestors 'none'",
69
+ "object-src 'none'",
70
+ "script-src 'self'",
71
+ "style-src 'self'",
72
+ "upgrade-insecure-requests"
73
+ ].join("; ").freeze
41
74
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: header_guard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gemini AI
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-08 00:00:00.000000000 Z
12
+ date: 2026-09-12 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack