header_guard 0.1.1 → 0.2.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: e1b0c70ef3860e8eb858d0f1788b6f77693dd832c49a26f4a6d7d30a8e16989a
4
- data.tar.gz: b7938f96c5900e6fa4f7f80034baf438277b9759592d5136c072d0de94630b79
3
+ metadata.gz: ea2dc2323334a13252e61e46ada4dbc84657d0a0f998ee5b1d4481d7c10c0a1b
4
+ data.tar.gz: 840fa96c9bb743cd59c4aea1a92adf80c116ccb6615af5c9f4d1a34ea3c16b6e
5
5
  SHA512:
6
- metadata.gz: e38ca2186bcfe23bcd7fce5b6c48d660f007bef259f36ff527858c43a742fdc60bd9ed417a0821d2852a3ea840c4688e5a621265579ad85ae3dda6de87630342
7
- data.tar.gz: fceb2b577b294e6d026ddda01416b4c09ef48e88b86db274242f68993f72882da8e1ad9b60fe5bbec24aab29ae4776049cd90e35d11df0eb50e97add9d76c9c6
6
+ metadata.gz: 37cd5fd01fc7d57b0ad5ee1282115f5253552dcb93b9026bf78b8890b75d83d9781a3c85c821e37556432b94a2a9a36dda00eff1061582a51f12855e6b31c908
7
+ data.tar.gz: 18e56009b28987ccfcdbb78646d07fdf13f0ab9389c02e1d985aef41ec615f020c4012e4d01d3a69de8c48637e6b0de8e7b5ccdae81df15b9573f98b448864b0
data/CHANGELOG.md ADDED
@@ -0,0 +1,165 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ See `PLAN.md` for the remaining remediation work (P3–P5), targeted at 0.3.0.
11
+
12
+ ## [0.2.0] - 2026-09-12
13
+
14
+ Widens which responses receive security headers and hardens every default. Read
15
+ **Upgrading from 0.1.x** at the end of this entry before deploying.
16
+
17
+ ### Changed
18
+
19
+ - **Standard headers are now applied to every response.** `Strict-Transport-Security`,
20
+ `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy` were previously
21
+ injected only on 2xx responses with an HTML content type, which excluded the responses
22
+ that need them most: JSON bodies got no `nosniff`, and the HTTP→HTTPS redirect — the
23
+ response where HSTS matters — got no HSTS. They now go on every response regardless
24
+ of status code or content type.
25
+
26
+ - **The Content Security Policy is now applied to HTML responses of every status.**
27
+ Previously only 2xx HTML responses received a CSP, leaving 4xx/5xx error pages
28
+ unprotected. Error pages routinely reflect user input and are a classic XSS surface.
29
+ CSP remains restricted to HTML content types, as it governs documents only.
30
+
31
+ - `application/xhtml+xml` is now treated as HTML for CSP purposes alongside `text/html`.
32
+
33
+ - **`preload` removed from the default `Strict-Transport-Security`.** Preload is the
34
+ opt-in signal for the browser HSTS preload list, which hard-codes the apex domain and
35
+ every subdomain as HTTPS-only inside the browser itself; removal takes months. That is
36
+ a commitment to make explicitly, not one to acquire by adding a middleware. The default
37
+ is now `max-age=31536000; includeSubDomains`. Opt back in by overriding the header.
38
+
39
+ - **Default CSP tightened to a strict same-origin baseline.** `style-src` was
40
+ `'self' 'unsafe-inline' https:` and `font-src` was `'self' https: data:`. Both are now
41
+ `'self'`. `https:` as a source allows content from any HTTPS origin and `'unsafe-inline'`
42
+ allows arbitrary inline styles — either lets an attacker who can inject markup load or
43
+ run content of their choosing, which is a weak default for something billed as a secure
44
+ baseline. Applications that need inline styles should add `'unsafe-inline'` deliberately
45
+ via `content_security_policy:`.
46
+
47
+ - Default CSP directives are now separated by `"; "` rather than `";"` for readability.
48
+ Semantically identical.
49
+
50
+ ### Added
51
+
52
+ - `html_only: true` option, which restores the 0.1.x behaviour of injecting nothing
53
+ unless the response is a 2xx with an HTML content type. This is a migration aid for
54
+ applications that depended on the narrower scope, not a recommended configuration.
55
+
56
+ - **Four new default headers:**
57
+ - `Cross-Origin-Opener-Policy: same-origin-allow-popups` — isolates the browsing
58
+ context from cross-origin openers, mitigating XS-Leaks and Spectre-class attacks,
59
+ while still allowing popups the site itself opens (OAuth/OIDC providers in popup
60
+ mode) to talk back via `window.opener`. Chosen over the stricter `same-origin`
61
+ because the gem targets SSO applications, where popup-based client SDKs are common;
62
+ the only protection given up is against windows the site's own code chose to open.
63
+ - `Cross-Origin-Resource-Policy: same-origin` — stops other origins embedding this
64
+ site's resources via no-cors requests.
65
+ - `X-Permitted-Cross-Domain-Policies: none` — forbids Flash/Acrobat cross-domain
66
+ policy files.
67
+ - `Permissions-Policy` — denies `accelerometer`, `camera`, `geolocation`, `gyroscope`,
68
+ `magnetometer`, `microphone`, `payment` and `usb` unless explicitly enabled.
69
+
70
+ ### Removed
71
+
72
+ - `block-all-mixed-content` from the default CSP. The directive is deprecated and was
73
+ removed from CSP Level 3; `upgrade-insecure-requests`, which remains, covers it.
74
+
75
+ ### Upgrading from 0.1.x
76
+
77
+ Read this section before deploying — 0.2.0 tightens several defaults and some
78
+ applications will need to opt back into behaviour they relied on.
79
+
80
+ - **Standard headers now land on every response.** If your application sets its own
81
+ value for one of them on a non-HTML response (for example a different
82
+ `X-Frame-Options` on an API endpoint), HeaderGuard now overwrites it there too, as it
83
+ always has on HTML. Pass the desired value as a custom header option, or use
84
+ `html_only: true` while you migrate.
85
+ - **Inline styles are now blocked by the default CSP.** If your pages use inline
86
+ `<style>` or `style=""` attributes (CSS-in-JS libraries commonly do), pass a
87
+ `content_security_policy:` that adds `'unsafe-inline'` to `style-src`, or better, move
88
+ to nonces.
89
+ - **Third-party fonts and stylesheets are now blocked by the default CSP.** Add the
90
+ specific origins you use (e.g. `https://fonts.googleapis.com`) to `style-src` and
91
+ `font-src` rather than reinstating `https:`.
92
+ - **Identity providers serving popup-based flows need to override
93
+ `Cross-Origin-Opener-Policy`.** The default `same-origin-allow-popups` keeps flows
94
+ where *your* site opens the popup working, but if your site *is* the popup (you are
95
+ the identity provider), the page the client opens must keep `window.opener`, which
96
+ requires `unsafe-none`. Redirect-based flows are unaffected either way.
97
+ - **Assets embedded by other sites will be blocked by `Cross-Origin-Resource-Policy`.**
98
+ If your app serves images, scripts or fonts meant to load on other origins, override
99
+ with `cross-origin`.
100
+ - **Features denied by `Permissions-Policy`** (camera, microphone, geolocation, payment,
101
+ and so on) must be re-enabled explicitly if your app uses them.
102
+ - **HSTS preload is no longer set.** If you had already submitted your domain to the
103
+ preload list, add `preload` back via a custom header — otherwise the list's periodic
104
+ checks will flag the domain for removal.
105
+
106
+ ## [0.1.2] - 2026-09-08
107
+
108
+ ### Fixed
109
+
110
+ - **Security headers were silently not injected on conformant Rack 3 applications.**
111
+ The HTML check read `headers["Content-Type"]`, but the Rack 3 SPEC requires response
112
+ header keys to be lowercase. An application returning a plain Hash such as
113
+ `{"content-type" => "text/html"}` received **no** security headers at all — no HSTS,
114
+ no CSP, no `nosniff` — with no error raised. The gem only appeared to work for
115
+ applications built on `Rack::Response`, which returns a case-insensitive
116
+ `Rack::Headers`. Header lookup is now case-insensitive, so both Rack 2 and Rack 3
117
+ style responses are detected.
118
+
119
+ - **Emitted header names violated the Rack 3 SPEC.** Headers were written with
120
+ capitalized keys (`Strict-Transport-Security`), which `Rack::Lint` rejects with
121
+ `uppercase character in header name`. All injected headers are now written in
122
+ lowercase.
123
+
124
+ - **A header set by the application under a different capitalization is no longer
125
+ duplicated.** Previously an app setting `X-Frame-Options` would end up with both its
126
+ own key and the middleware's, sending the header twice. Differently-cased duplicates
127
+ are removed before the middleware writes its value.
128
+
129
+ - **A custom header option now overrides the matching default regardless of case.**
130
+ Passing `"x-frame-options" => "SAMEORIGIN"` previously appended a second header
131
+ instead of replacing the default.
132
+
133
+ - **Previously released `.gem` files are no longer packaged inside new releases.**
134
+ `header_guard-0.1.0.gem` and `header_guard-0.1.1.gem` were tracked in git, and the
135
+ gemspec filtered only the *current* version's file, so each release bundled every
136
+ earlier one — 0.1.1 was 16KB largely because it contained 0.1.0. The artifacts are now
137
+ untracked and gitignored, and the gemspec rejects any `.gem` file rather than one
138
+ specific name. `PLAN.md` is excluded from the package as well.
139
+
140
+ ### Changed
141
+
142
+ - Injected response header names are now lowercase. This is invisible over HTTP, where
143
+ header names are case-insensitive, but code inspecting the raw Rack headers Hash by an
144
+ exact capitalized key needs to be updated. `Rack::Headers` and `Rack::Test` lookups are
145
+ unaffected.
146
+
147
+ ## [0.1.1] - 2025-10-20
148
+
149
+ ### Fixed
150
+
151
+ - Corrected the `homepage` metadata in the gemspec.
152
+
153
+ ## [0.1.0] - 2025-10-20
154
+
155
+ ### Added
156
+
157
+ - Initial release: Rack middleware injecting HSTS, `X-Content-Type-Options`,
158
+ `X-Frame-Options`, `Referrer-Policy` and a configurable Content Security Policy,
159
+ with `report_only` support.
160
+
161
+ [Unreleased]: https://github.com/danielefrisanco/headerguard/compare/v0.2.0...HEAD
162
+ [0.2.0]: https://github.com/danielefrisanco/headerguard/compare/v0.1.2...v0.2.0
163
+ [0.1.2]: https://github.com/danielefrisanco/headerguard/compare/v0.1.1...v0.1.2
164
+ [0.1.1]: https://github.com/danielefrisanco/headerguard/compare/v0.1.0...v0.1.1
165
+ [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.1)
4
+ header_guard (0.2.0)
5
5
  rack (~> 3.0)
6
6
 
7
7
  GEM
@@ -35,4 +35,4 @@ DEPENDENCIES
35
35
  rspec (~> 3.12)
36
36
 
37
37
  BUNDLED WITH
38
- 2.4.22
38
+ 2.4.9
data/README.md CHANGED
@@ -82,14 +82,18 @@ When integrating `HeaderGuard` into your project, you can pass an options hash t
82
82
 
83
83
  #### 1\. Overriding Standard Headers
84
84
 
85
- Any key/value pair passed to the middleware that matches a standard header will override the default value.
86
-
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. |
85
+ 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
+
87
+ | Header | Default Value | Purpose |
88
+ | ----- | ----- | ----- |
89
+ | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | 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. |
93
+ | `Cross-Origin-Opener-Policy` | `same-origin-allow-popups` | Isolates the browsing context from cross-origin openers (XS-Leaks, Spectre) while allowing popups you open. |
94
+ | `Cross-Origin-Resource-Policy` | `same-origin` | Stops other origins embedding your resources via no-cors requests. |
95
+ | `X-Permitted-Cross-Domain-Policies` | `none` | Forbids Flash/Acrobat cross-domain policy files. |
96
+ | `Permissions-Policy` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` | Denies sensitive device features unless enabled. |
93
97
 
94
98
  **Example: Overriding X-Frame-Options and Referrer-Policy:**
95
99
  ```ruby
@@ -98,14 +102,55 @@ config.middleware.use HeaderGuard::Middleware,
98
102
  "X-Frame-Options" => "SAMEORIGIN",
99
103
  "Referrer-Policy" => "no-referrer"
100
104
  ```
105
+
106
+ **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:
107
+
108
+ ```ruby
109
+ config.middleware.use HeaderGuard::Middleware,
110
+ "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload"
111
+ ```
112
+
113
+ **`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.
114
+
115
+ Two situations need a different value:
116
+
117
+ ```ruby
118
+ # Your site *is* the popup (you are the identity provider): the page the client
119
+ # opens must keep window.opener, which requires disabling isolation on it.
120
+ config.middleware.use HeaderGuard::Middleware, "Cross-Origin-Opener-Policy" => "unsafe-none"
121
+
122
+ # You open no popups and want the strictest isolation available (also the
123
+ # value required, together with COEP, for cross-origin isolated features such
124
+ # as SharedArrayBuffer):
125
+ config.middleware.use HeaderGuard::Middleware, "Cross-Origin-Opener-Policy" => "same-origin"
126
+ ```
127
+
128
+ **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
129
  #### 2\. Custom Content Security Policy (CSP)
102
130
 
103
- You can define a custom CSP string to replace the secure default provided by HeaderGuard.
131
+ The default CSP is a strict same-origin baseline:
132
+
133
+ ```
134
+ default-src 'self'; base-uri 'self'; font-src 'self'; form-action 'self';
135
+ frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self';
136
+ upgrade-insecure-requests
137
+ ```
138
+
139
+ 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
140
 
105
141
  ```ruby
106
- custom_csp = "default-src 'self'; script-src 'self' [https://trusted.cdn.com](https://trusted.cdn.com);"
142
+ custom_csp = "default-src 'self'; script-src 'self' https://trusted.cdn.com;"
107
143
  use HeaderGuard::Middleware, content_security_policy: custom_csp
108
144
 
145
+ ```
146
+
147
+ 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:
148
+
149
+ ```ruby
150
+ # Allowing inline styles, explicitly.
151
+ use HeaderGuard::Middleware,
152
+ content_security_policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'"
153
+
109
154
  ```
110
155
  #### 3\. Report-Only Mode
111
156
 
@@ -116,18 +161,29 @@ To test a new CSP without enforcing it, set the report\_only option to true. Thi
116
161
  config.middleware.use HeaderGuard::Middleware, report_only: true
117
162
 
118
163
  ```
164
+ #### 4\. Restricting to HTML Responses (legacy behaviour)
165
+
166
+ 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`:
167
+
168
+ ```ruby
169
+ # 0.1.x behaviour: inject only on 2xx text/html responses.
170
+ config.middleware.use HeaderGuard::Middleware, html_only: true
171
+
172
+ ```
173
+ 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.
119
174
 
120
175
  How It Works
121
176
  ------------
122
177
 
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:
178
+ HeaderGuard hooks into the Rack request lifecycle and, on every response passing through it:
124
179
 
125
180
  1. **Header Merging:** It takes the default security headers and merges them with any custom headers supplied during initialization, ensuring user configuration takes precedence.
126
181
 
127
- 2. **Injection:** It injects the final set of standard security headers.
182
+ 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
183
 
129
- 3. **CSP Injection:** It injects the configured Content Security Policy, using either the standard enforcement header or the Report-Only header.
184
+ 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
185
 
186
+ 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
187
 
132
188
  Development
133
189
  -----------
data/header_guard.gemspec CHANGED
@@ -12,11 +12,15 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Designed for applications that require strong browser-side security, HeaderGuard automatically injects HSTS, X-Content-Type-Options, X-Frame-Options, and a customizable CSP. Ideal for SSO and high-security web services."
13
13
  spec.homepage = "https://github.com/danielefrisanco/headerguard"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.6.6"
15
+ spec.required_ruby_version = ">= 2.6.6"
16
16
  # Specify which files should be added to the gem when it is released.
17
17
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
18
18
  `git ls-files -z`.split("\x0").reject do |f|
19
- (f == spec.full_name + ".gem") ||
19
+ # Never package build artifacts or internal planning docs. Matching any
20
+ # ".gem" (not just the current version's) keeps a stray local build from
21
+ # being bundled into a release.
22
+ f.end_with?(".gem") ||
23
+ f == "PLAN.md" ||
20
24
  f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
21
25
  end
22
26
  end
@@ -2,53 +2,133 @@
2
2
 
3
3
  module HeaderGuard
4
4
  # The Rack middleware class responsible for injecting security headers.
5
+ #
6
+ # Header names are treated case-insensitively throughout. The Rack 3 SPEC
7
+ # requires response header keys to be lowercase, while Rack 2 applications
8
+ # conventionally use capitalized keys, so this middleware reads incoming
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 or
15
+ # content type. HSTS matters most on the HTTP->HTTPS redirect, and nosniff
16
+ # 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
+ # Passing `html_only: true` restores the 0.1.x behaviour, where nothing was
23
+ # injected unless the response was a 2xx with an HTML content type.
5
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
+
6
28
  # Initializes the middleware. It merges user-defined options over defaults.
7
29
  #
8
30
  # @param app [Object] The next application in the Rack stack.
9
31
  # @param options [Hash] Configuration options for headers and CSP.
10
32
  def initialize(app, options = {})
11
33
  @app = app
12
-
34
+
13
35
  # Use a copy of options for configuration extraction
14
36
  config = options.dup
15
37
 
16
38
  # Extract special configuration settings
17
39
  custom_csp = config.delete(:content_security_policy)
18
40
  @report_only = config.delete(:report_only) || false
41
+ @html_only = config.delete(:html_only) || false
19
42
 
20
43
  # 1. Start with DEFAULT_HEADERS (from header_guard.rb)
21
- # 2. Merge remaining options (which are custom headers) over the defaults.
22
- # THIS IS THE FIX: By merging 'config' (which now only contains headers)
23
- # over the defaults, the user's header values take precedence.
24
- @headers = DEFAULT_HEADERS.merge(config).freeze
44
+ # 2. Merge remaining options (which are custom headers) over the defaults,
45
+ # so the user's header values take precedence.
46
+ # 3. Normalize every key to lowercase, so a custom "X-Frame-Options"
47
+ # overrides the default rather than being emitted alongside it.
48
+ @headers = DEFAULT_HEADERS.merge(config).each_with_object({}) do |(key, value), normalized|
49
+ normalized[normalize_key(key)] = value
50
+ end.freeze
25
51
 
26
52
  # Set the final CSP value and the header key based on report_only setting
27
53
  @csp_value = custom_csp || DEFAULT_CSP
28
- @csp_header_key = @report_only ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"
54
+ @csp_header_key = @report_only ? "content-security-policy-report-only" : "content-security-policy"
29
55
  end
30
56
 
31
57
  # The Rack application call method.
32
58
  def call(env)
33
59
  status, headers, body = @app.call(env)
34
60
 
35
- # Only inject headers on successful (2xx) responses with HTML content.
36
- # Exclude redirects, errors, and non-HTML assets (like JSON or images).
37
- if (200..299).include?(status) && headers["Content-Type"]&.include?("text/html")
38
-
39
- # Inject the standard headers
61
+ html = html?(headers)
62
+
63
+ if apply_standard_headers?(status, html)
40
64
  @headers.each do |key, value|
41
- # We use assignment (=) here, not `||=`, to ensure the middleware
65
+ # We use assignment here, not `||=`, to ensure the middleware
42
66
  # overwrites any headers set by the application before it,
43
67
  # adhering to the strong security posture.
44
- headers[key] = value
68
+ assign(headers, key, value)
45
69
  end
46
-
47
- # Inject the configured CSP header
48
- headers[@csp_header_key] = @csp_value
49
70
  end
50
71
 
72
+ assign(headers, @csp_header_key, @csp_value) if apply_csp?(status, html)
73
+
51
74
  [status, headers, body]
52
75
  end
76
+
77
+ private
78
+
79
+ # Standard headers go on every response. In html_only mode they are
80
+ # restricted to 2xx HTML, as in 0.1.x.
81
+ def apply_standard_headers?(status, html)
82
+ return true unless @html_only
83
+
84
+ success?(status) && html
85
+ end
86
+
87
+ # CSP goes on every HTML response regardless of status. In html_only mode
88
+ # it is restricted to 2xx HTML, as in 0.1.x.
89
+ def apply_csp?(status, html)
90
+ return false unless html
91
+ return true unless @html_only
92
+
93
+ success?(status)
94
+ end
95
+
96
+ def success?(status)
97
+ (200..299).cover?(status)
98
+ end
99
+
100
+ def normalize_key(key)
101
+ key.to_s.downcase
102
+ end
103
+
104
+ def html?(headers)
105
+ content_type = fetch_header(headers, "content-type")
106
+ return false unless content_type
107
+
108
+ HTML_CONTENT_TYPES.any? { |type| content_type.include?(type) }
109
+ end
110
+
111
+ # Rack 3 responses key headers in lowercase; Rack 2 applications typically
112
+ # send "Content-Type". A Rack::Headers hash resolves either directly, but a
113
+ # plain Hash does not, so fall back to scanning for a case-insensitive match.
114
+ def fetch_header(headers, key)
115
+ return headers[key] if headers.key?(key)
116
+
117
+ match = headers.keys.find { |candidate| normalize_key(candidate) == key }
118
+ match && headers[match]
119
+ end
120
+
121
+ # Writes the lowercase key, first removing any differently-cased duplicate
122
+ # the application may have set, so the response never carries the same
123
+ # header twice under two spellings.
124
+ def assign(headers, key, value)
125
+ headers.keys.each do |existing|
126
+ next if existing == key
127
+
128
+ headers.delete(existing) if normalize_key(existing) == key
129
+ end
130
+
131
+ headers[key] = value
132
+ end
53
133
  end
54
134
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module HeaderGuard
4
4
  # The current version of the HeaderGuard gem.
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.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.1
4
+ version: 0.2.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: 2025-10-20 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
@@ -62,11 +62,11 @@ executables: []
62
62
  extensions: []
63
63
  extra_rdoc_files: []
64
64
  files:
65
+ - CHANGELOG.md
65
66
  - Gemfile
66
67
  - Gemfile.lock
67
68
  - LICENSE.txt
68
69
  - README.md
69
- - header_guard-0.1.0.gem
70
70
  - header_guard.gemspec
71
71
  - lib/header_guard.rb
72
72
  - lib/header_guard/middleware.rb
@@ -90,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
90
  - !ruby/object:Gem::Version
91
91
  version: '0'
92
92
  requirements: []
93
- rubygems_version: 3.2.3
93
+ rubygems_version: 3.3.26
94
94
  signing_key:
95
95
  specification_version: 4
96
96
  summary: A robust Rack middleware for enforcing modern HTTP security headers, including
Binary file