header_guard 0.2.0 → 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 +4 -4
- data/CHANGELOG.md +48 -2
- data/Gemfile.lock +1 -1
- data/README.md +67 -6
- data/lib/header_guard/middleware.rb +196 -34
- data/lib/header_guard/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fde99ae71b2986c94287ef5b903ba52d00405019346f9c2f61a88c5c81dd24e
|
|
4
|
+
data.tar.gz: c7af2219fc4d05c19c2923184515921cc91f37e0cbbdb69ef65ae4d0b778c668
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74bb808854cc17a68c1a48d7c424e7e177dd30dcbbe4ae9ecfe5f5fa446475377c443a70a57529bc3a9e5d37dddcb2d39f3008a6da75bf5ddff6f5d6ebf3ee43
|
|
7
|
+
data.tar.gz: 226cbb1dd847db813eb69c236c59591440f31906683f8e644f1d3dd076e908aeda2c551abae859befd86b8b5909cd05946fa9530c9b0a33dd908efc18616f5b5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,52 @@ 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
|
|
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`.
|
|
11
56
|
|
|
12
57
|
## [0.2.0] - 2026-09-12
|
|
13
58
|
|
|
@@ -158,7 +203,8 @@ applications will need to opt back into behaviour they relied on.
|
|
|
158
203
|
`X-Frame-Options`, `Referrer-Policy` and a configurable Content Security Policy,
|
|
159
204
|
with `report_only` support.
|
|
160
205
|
|
|
161
|
-
[Unreleased]: https://github.com/danielefrisanco/headerguard/compare/v0.
|
|
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
|
|
162
208
|
[0.2.0]: https://github.com/danielefrisanco/headerguard/compare/v0.1.2...v0.2.0
|
|
163
209
|
[0.1.2]: https://github.com/danielefrisanco/headerguard/compare/v0.1.1...v0.1.2
|
|
164
210
|
[0.1.1]: https://github.com/danielefrisanco/headerguard/compare/v0.1.0...v0.1.1
|
data/Gemfile.lock
CHANGED
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.
|
|
64
|
-
|
|
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
|
-
#
|
|
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
|
|
|
@@ -172,12 +174,71 @@ config.middleware.use HeaderGuard::Middleware, html_only: true
|
|
|
172
174
|
```
|
|
173
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.
|
|
174
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.
|
|
235
|
+
|
|
175
236
|
How It Works
|
|
176
237
|
------------
|
|
177
238
|
|
|
178
239
|
HeaderGuard hooks into the Rack request lifecycle and, on every response passing through it:
|
|
179
240
|
|
|
180
|
-
1. **Header Merging:**
|
|
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.
|
|
181
242
|
|
|
182
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.
|
|
183
244
|
|
|
@@ -11,57 +11,79 @@ module HeaderGuard
|
|
|
11
11
|
# Which responses receive which headers:
|
|
12
12
|
#
|
|
13
13
|
# * The standard headers (HSTS, X-Content-Type-Options, X-Frame-Options,
|
|
14
|
-
# Referrer-Policy) are applied to every response, regardless of status
|
|
15
|
-
# content type. HSTS matters most on the HTTP->HTTPS redirect, and
|
|
16
|
-
# exists precisely to protect non-HTML bodies such as JSON.
|
|
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
17
|
# * The Content Security Policy governs a document, so it is applied only to
|
|
18
18
|
# HTML responses -- but on every status. Error pages reflect user input and
|
|
19
19
|
# are a classic XSS surface, so they need a policy at least as much as a
|
|
20
20
|
# 200 does.
|
|
21
21
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Options are validated at construction and raise ArgumentError on anything
|
|
23
|
+
# unrecognised or malformed, so a typo cannot silently weaken the policy.
|
|
24
24
|
class Middleware
|
|
25
25
|
# Content types treated as HTML documents for the purpose of applying CSP.
|
|
26
26
|
HTML_CONTENT_TYPES = ["text/html", "application/xhtml+xml"].freeze
|
|
27
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
|
+
|
|
28
56
|
# Initializes the middleware. It merges user-defined options over defaults.
|
|
29
57
|
#
|
|
30
58
|
# @param app [Object] The next application in the Rack stack.
|
|
31
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.
|
|
32
62
|
def initialize(app, options = {})
|
|
33
63
|
@app = app
|
|
34
64
|
|
|
35
|
-
|
|
36
|
-
config = options.dup
|
|
37
|
-
|
|
38
|
-
# Extract special configuration settings
|
|
39
|
-
custom_csp = config.delete(:content_security_policy)
|
|
40
|
-
@report_only = config.delete(:report_only) || false
|
|
41
|
-
@html_only = config.delete(:html_only) || false
|
|
65
|
+
raise ArgumentError, "HeaderGuard options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
normalized[normalize_key(key)] = value
|
|
50
|
-
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
|
+
)
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
@
|
|
54
|
-
@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])
|
|
55
76
|
end
|
|
56
77
|
|
|
57
78
|
# The Rack application call method.
|
|
58
79
|
def call(env)
|
|
59
80
|
status, headers, body = @app.call(env)
|
|
60
81
|
|
|
82
|
+
policy = policy_for(env["PATH_INFO"])
|
|
61
83
|
html = html?(headers)
|
|
62
84
|
|
|
63
|
-
if apply_standard_headers?(status, html)
|
|
64
|
-
|
|
85
|
+
if apply_standard_headers?(policy, status, html)
|
|
86
|
+
policy.headers.each do |key, value|
|
|
65
87
|
# We use assignment here, not `||=`, to ensure the middleware
|
|
66
88
|
# overwrites any headers set by the application before it,
|
|
67
89
|
# adhering to the strong security posture.
|
|
@@ -69,26 +91,170 @@ module HeaderGuard
|
|
|
69
91
|
end
|
|
70
92
|
end
|
|
71
93
|
|
|
72
|
-
assign(headers,
|
|
94
|
+
assign(headers, policy.csp_key, policy.csp_value) if policy.csp_value && apply_csp?(policy, status, html)
|
|
73
95
|
|
|
74
96
|
[status, headers, body]
|
|
75
97
|
end
|
|
76
98
|
|
|
77
99
|
private
|
|
78
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
|
+
|
|
225
|
+
def normalize_key(key)
|
|
226
|
+
key.to_s.downcase
|
|
227
|
+
end
|
|
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
|
+
|
|
79
245
|
# Standard headers go on every response. In html_only mode they are
|
|
80
246
|
# restricted to 2xx HTML, as in 0.1.x.
|
|
81
|
-
def apply_standard_headers?(status, html)
|
|
82
|
-
return true unless
|
|
247
|
+
def apply_standard_headers?(policy, status, html)
|
|
248
|
+
return true unless policy.html_only
|
|
83
249
|
|
|
84
250
|
success?(status) && html
|
|
85
251
|
end
|
|
86
252
|
|
|
87
253
|
# CSP goes on every HTML response regardless of status. In html_only mode
|
|
88
254
|
# it is restricted to 2xx HTML, as in 0.1.x.
|
|
89
|
-
def apply_csp?(status, html)
|
|
255
|
+
def apply_csp?(policy, status, html)
|
|
90
256
|
return false unless html
|
|
91
|
-
return true unless
|
|
257
|
+
return true unless policy.html_only
|
|
92
258
|
|
|
93
259
|
success?(status)
|
|
94
260
|
end
|
|
@@ -97,10 +263,6 @@ module HeaderGuard
|
|
|
97
263
|
(200..299).cover?(status)
|
|
98
264
|
end
|
|
99
265
|
|
|
100
|
-
def normalize_key(key)
|
|
101
|
-
key.to_s.downcase
|
|
102
|
-
end
|
|
103
|
-
|
|
104
266
|
def html?(headers)
|
|
105
267
|
content_type = fetch_header(headers, "content-type")
|
|
106
268
|
return false unless content_type
|
data/lib/header_guard/version.rb
CHANGED