Skip to content

Commit be13243

Browse files
authored
Merge pull request #383 from ptoomey3/secure-headers-x
Rewrite the entire notion of named overrides (breaking change)
2 parents cdfbe4d + 9733c3b commit be13243

24 files changed

+313
-370
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 6.0
2+
3+
- See the [upgrading to 6.0](docs/upgrading-to-6-0.md) guide for the breaking changes.
4+
=======
15
## 5.0.5
26

37
A release to deprecate `SecureHeaders::Configuration#get` in prep for 6.x

docs/upgrading-to-6-0.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
## Named overrides are now dynamically applied
2+
3+
The original implementation of name overrides worked by making a copy of the default policy, applying the overrides, and storing the result for later use. But, this lead to unexpected results if named overrides were combined with a dynamic policy change. If a change was made to the default configuration during a request, followed by a named override, the dynamic changes would be lost. To keep things consistent named overrides have been rewritten to work the same as named appends in that they always operate on the configuration for the current request. As an example:
4+
5+
```ruby
6+
class ApplicationController < ActionController::Base
7+
Configuration.default do |config|
8+
config.x_frame_options = OPT_OUT
9+
end
10+
11+
SecureHeaders::Configuration.override(:dynamic_override) do |config|
12+
config.x_content_type_options = "nosniff"
13+
end
14+
end
15+
16+
class FooController < ApplicationController
17+
def bar
18+
# Dynamically update the default config for this request
19+
override_x_frame_options("DENY")
20+
append_content_security_policy_directives(frame_src: "3rdpartyprovider.com")
21+
22+
# Override everything, discard modifications above
23+
use_secure_headers_override(:dynamic_override)
24+
end
25+
end
26+
```
27+
28+
Prior to 6.0.0, the response would NOT include a `X-Frame-Options` header since the named override would be a copy of the default configuration, but with `X-Content-Type-Options` set to `nosniff`. As of 6.0.0, the above code results in both `X-Frame-Options` set to `DENY` AND `X-Content-Type-Options` set to `nosniff`.
29+
30+
## `ContentSecurityPolicyConfig#merge` and `ContentSecurityPolicyReportOnlyConfig#merge` work more like `Hash#merge`
31+
32+
These classes are typically not directly instantiated by users of SecureHeaders. But, if you access `config.csp` you end up accessing one of these objects. Prior to 6.0.0, `#merge` worked more like `#append` in that it would combine policies (i.e. if both policies contained the same key the values would be combined rather than overwritten). This was not consistent with `#merge!`, which worked more like ruby's `Hash#merge!` (overwriting duplicate keys). As of 6.0.0, `#merge` works the same as `#merge!`, but returns a new object instead of mutating `self`.
33+
34+
## `Configuration#get` has been removed
35+
36+
This method is not typically directly called by users of SecureHeaders. Given that named overrides are no longer statically stored, fetching them no longer makes sense.
37+
38+
## Configuration headers are no longer cached
39+
40+
Prior to 6.0.0 SecureHeaders pre-built and cached the headers that corresponded to the default configuration. The same was also done for named overrides. However, now that named overrides are applied dynamically, those can no longer be cached. As a result, caching has been removed in the name of simplicity. Some micro-benchmarks indicate this shouldn't be a performance problem and will help to eliminate a class of bugs entirely.
41+
42+
## Configuration the default configuration more than once will result in an Exception
43+
44+
Prior to 6.0.0 you could conceivably, though unlikely, have `Configure#default` called more than once. Because configurations are dynamic, configuring more than once could result in unexpected behavior. So, as of 6.0.0 we raise `AlreadyConfiguredError` if the default configuration is setup more than once.

lib/secure_headers.rb

Lines changed: 14 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# frozen_string_literal: true
2-
require "secure_headers/configuration"
32
require "secure_headers/hash_helper"
43
require "secure_headers/headers/cookie"
54
require "secure_headers/headers/public_key_pins"
@@ -18,6 +17,7 @@
1817
require "secure_headers/view_helper"
1918
require "useragent"
2019
require "singleton"
20+
require "secure_headers/configuration"
2121

2222
# All headers (except for hpkp) have a default value. Provide SecureHeaders::OPT_OUT
2323
# or ":optout_of_protection" as a config value to disable a given header
@@ -52,29 +52,9 @@ def opt_out?
5252
HTTPS = "https".freeze
5353
CSP = ContentSecurityPolicy
5454

55-
ALL_HEADER_CLASSES = [
56-
ExpectCertificateTransparency,
57-
ClearSiteData,
58-
ContentSecurityPolicyConfig,
59-
ContentSecurityPolicyReportOnlyConfig,
60-
StrictTransportSecurity,
61-
PublicKeyPins,
62-
ReferrerPolicy,
63-
XContentTypeOptions,
64-
XDownloadOptions,
65-
XFrameOptions,
66-
XPermittedCrossDomainPolicies,
67-
XXssProtection
68-
].freeze
69-
70-
ALL_HEADERS_BESIDES_CSP = (
71-
ALL_HEADER_CLASSES -
72-
[ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig]
73-
).freeze
74-
7555
# Headers set on http requests (excludes STS and HPKP)
76-
HTTP_HEADER_CLASSES =
77-
(ALL_HEADER_CLASSES - [StrictTransportSecurity, PublicKeyPins]).freeze
56+
HTTPS_HEADER_CLASSES =
57+
[StrictTransportSecurity, PublicKeyPins].freeze
7858

7959
class << self
8060
# Public: override a given set of directives for the current request. If a
@@ -153,7 +133,7 @@ def opt_out_of_header(request, header_key)
153133
# Public: opts out of setting all headers by telling secure_headers to use
154134
# the NOOP configuration.
155135
def opt_out_of_all_protection(request)
156-
use_secure_headers_override(request, Configuration::NOOP_CONFIGURATION)
136+
use_secure_headers_override(request, Configuration::NOOP_OVERRIDE)
157137
end
158138

159139
# Public: Builds the hash of headers that should be applied base on the
@@ -168,39 +148,26 @@ def opt_out_of_all_protection(request)
168148
def header_hash_for(request)
169149
prevent_dup = true
170150
config = config_for(request, prevent_dup)
171-
headers = config.cached_headers
151+
config.validate_config!
172152
user_agent = UserAgent.parse(request.user_agent)
153+
headers = config.generate_headers(user_agent)
173154

174-
if !config.csp.opt_out? && config.csp.modified?
175-
headers = update_cached_csp(config.csp, headers, user_agent)
176-
end
177-
178-
if !config.csp_report_only.opt_out? && config.csp_report_only.modified?
179-
headers = update_cached_csp(config.csp_report_only, headers, user_agent)
180-
end
181-
182-
header_classes_for(request).each_with_object({}) do |klass, hash|
183-
if header = headers[klass::CONFIG_KEY]
184-
header_name, value = if klass == ContentSecurityPolicyConfig || klass == ContentSecurityPolicyReportOnlyConfig
185-
csp_header_for_ua(header, user_agent)
186-
else
187-
header
188-
end
189-
hash[header_name] = value
155+
if request.scheme != HTTPS
156+
HTTPS_HEADER_CLASSES.each do |klass|
157+
headers.delete(klass::HEADER_NAME)
190158
end
191159
end
160+
headers
192161
end
193162

194163
# Public: specify which named override will be used for this request.
195164
# Raises an argument error if no named override exists.
196165
#
197166
# name - the name of the previously configured override.
198167
def use_secure_headers_override(request, name)
199-
if config = Configuration.get(name, internal: true)
200-
override_secure_headers_request_config(request, config)
201-
else
202-
raise ArgumentError.new("no override by the name of #{name} has been configured")
203-
end
168+
config = config_for(request)
169+
config.override(name)
170+
override_secure_headers_request_config(request, config)
204171
end
205172

206173
# Public: gets or creates a nonce for CSP.
@@ -228,7 +195,7 @@ def content_security_policy_style_nonce(request)
228195
# Falls back to the global config
229196
def config_for(request, prevent_dup = false)
230197
config = request.env[SECURE_HEADERS_CONFIG] ||
231-
Configuration.get(Configuration::DEFAULT_CONFIG, internal: true)
198+
Configuration.send(:default_config)
232199

233200

234201
# Global configs are frozen, per-request configs are not. When we're not
@@ -285,35 +252,6 @@ def content_security_policy_nonce(request, script_or_style)
285252
def override_secure_headers_request_config(request, config)
286253
request.env[SECURE_HEADERS_CONFIG] = config
287254
end
288-
289-
# Private: determines which headers are applicable to a given request.
290-
#
291-
# Returns a list of classes whose corresponding header values are valid for
292-
# this request.
293-
def header_classes_for(request)
294-
if request.scheme == HTTPS
295-
ALL_HEADER_CLASSES
296-
else
297-
HTTP_HEADER_CLASSES
298-
end
299-
end
300-
301-
def update_cached_csp(config, headers, user_agent)
302-
headers = Configuration.send(:deep_copy, headers)
303-
headers[config.class::CONFIG_KEY] = {}
304-
variation = ContentSecurityPolicy.ua_to_variation(user_agent)
305-
headers[config.class::CONFIG_KEY][variation] = ContentSecurityPolicy.make_header(config, user_agent)
306-
headers
307-
end
308-
309-
# Private: chooses the applicable CSP header for the provided user agent.
310-
#
311-
# headers - a hash of header_config_key => [header_name, header_value]
312-
#
313-
# Returns a CSP [header, value] array
314-
def csp_header_for_ua(headers, user_agent)
315-
headers[ContentSecurityPolicy.ua_to_variation(user_agent)]
316-
end
317255
end
318256

319257
# These methods are mixed into controllers and delegate to the class method

0 commit comments

Comments
 (0)