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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea2dc2323334a13252e61e46ada4dbc84657d0a0f998ee5b1d4481d7c10c0a1b
4
- data.tar.gz: 840fa96c9bb743cd59c4aea1a92adf80c116ccb6615af5c9f4d1a34ea3c16b6e
3
+ metadata.gz: 9fde99ae71b2986c94287ef5b903ba52d00405019346f9c2f61a88c5c81dd24e
4
+ data.tar.gz: c7af2219fc4d05c19c2923184515921cc91f37e0cbbdb69ef65ae4d0b778c668
5
5
  SHA512:
6
- metadata.gz: 37cd5fd01fc7d57b0ad5ee1282115f5253552dcb93b9026bf78b8890b75d83d9781a3c85c821e37556432b94a2a9a36dda00eff1061582a51f12855e6b31c908
7
- data.tar.gz: 18e56009b28987ccfcdbb78646d07fdf13f0ab9389c02e1d985aef41ec615f020c4012e4d01d3a69de8c48637e6b0de8e7b5ccdae81df15b9573f98b448864b0
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 remediation work (P3–P5), targeted at 0.3.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`.
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.2.0...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
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- header_guard (0.2.0)
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
 
@@ -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:** 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.
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 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.
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
- # 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.
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
- # Use a copy of options for configuration extraction
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
- # 1. Start with DEFAULT_HEADERS (from header_guard.rb)
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
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
- # Set the final CSP value and the header key based on report_only setting
53
- @csp_value = custom_csp || DEFAULT_CSP
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
- @headers.each do |key, value|
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, @csp_header_key, @csp_value) if apply_csp?(status, html)
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 @html_only
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 @html_only
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module HeaderGuard
4
4
  # The current version of the HeaderGuard gem.
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gemini AI