Skip to content

Commit 4fcc462

Browse files
committed
Merge pull request #232 from twitter/single-idempotency-check
Handle very dynamic policies better
2 parents 76fb828 + cbe7014 commit 4fcc462

File tree

6 files changed

+207
-155
lines changed

6 files changed

+207
-155
lines changed

lib/secure_headers.rb

Lines changed: 28 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,12 @@ class << self
4848
# script_src: %w(another-host.com)
4949
def override_content_security_policy_directives(request, additions)
5050
config = config_for(request)
51-
unless CSP.idempotent_additions?(config.csp, additions)
52-
config = config.dup
53-
if config.csp == OPT_OUT
54-
config.csp = {}
55-
end
56-
config.csp.merge!(additions)
57-
override_secure_headers_request_config(request, config)
51+
if config.current_csp == OPT_OUT
52+
config.dynamic_csp = {}
5853
end
54+
55+
config.dynamic_csp = config.current_csp.merge(additions)
56+
override_secure_headers_request_config(request, config)
5957
end
6058

6159
# Public: appends source values to the current configuration. If no value
@@ -66,11 +64,8 @@ def override_content_security_policy_directives(request, additions)
6664
# script_src: %w(another-host.com)
6765
def append_content_security_policy_directives(request, additions)
6866
config = config_for(request)
69-
unless CSP.idempotent_additions?(config.csp, additions)
70-
config = config.dup
71-
config.csp = CSP.combine_policies(config.csp, additions)
72-
override_secure_headers_request_config(request, config)
73-
end
67+
config.dynamic_csp = CSP.combine_policies(config.current_csp, additions)
68+
override_secure_headers_request_config(request, config)
7469
end
7570

7671
# Public: override X-Frame-Options settings for this request.
@@ -79,16 +74,16 @@ def append_content_security_policy_directives(request, additions)
7974
#
8075
# Returns the current config
8176
def override_x_frame_options(request, value)
82-
default_config = config_for(request).dup
83-
default_config.x_frame_options = value
84-
override_secure_headers_request_config(request, default_config)
77+
config = config_for(request)
78+
config.update_x_frame_options(value)
79+
override_secure_headers_request_config(request, config)
8580
end
8681

8782
# Public: opts out of setting a given header by creating a temporary config
8883
# and setting the given headers config to OPT_OUT.
8984
def opt_out_of_header(request, header_key)
90-
config = config_for(request).dup
91-
config.send("#{header_key}=", OPT_OUT)
85+
config = config_for(request)
86+
config.opt_out(header_key)
9287
override_secure_headers_request_config(request, config)
9388
end
9489

@@ -109,14 +104,11 @@ def opt_out_of_all_protection(request)
109104
# in Rack middleware.
110105
def header_hash_for(request)
111106
config = config_for(request)
112-
113-
headers = if cached_headers = config.cached_headers
114-
use_cached_headers(cached_headers, request)
115-
else
116-
build_headers(config, request)
107+
unless ContentSecurityPolicy.idempotent_additions?(config.csp, config.current_csp)
108+
config.rebuild_csp_header_cache!(request.user_agent)
117109
end
118110

119-
headers
111+
use_cached_headers(config.cached_headers, request)
120112
end
121113

122114
# Public: specify which named override will be used for this request.
@@ -155,8 +147,14 @@ def content_security_policy_style_nonce(request)
155147
# Checks to see if a named override is used for this request, then
156148
# Falls back to the global config
157149
def config_for(request)
158-
request.env[SECURE_HEADERS_CONFIG] ||
150+
config = request.env[SECURE_HEADERS_CONFIG] ||
159151
Configuration.get(Configuration::DEFAULT_CONFIG)
152+
153+
if config.frozen?
154+
config.dup
155+
else
156+
config
157+
end
160158
end
161159

162160
private
@@ -191,36 +189,17 @@ def header_classes_for(request)
191189
end
192190
end
193191

194-
# Private: do the heavy lifting of converting a configuration object
195-
# to a hash of headers valid for this request.
196-
#
197-
# Returns a hash of header names / values.
198-
def build_headers(config, request)
199-
header_classes_for(request).each_with_object({}) do |klass, hash|
200-
header_config = if config
201-
config.fetch(klass::CONFIG_KEY)
202-
end
203-
204-
header_name, value = if klass == CSP
205-
make_header(klass, header_config, request.user_agent)
206-
else
207-
make_header(klass, header_config)
208-
end
209-
hash[header_name] = value if value
210-
end
211-
end
212-
213192
# Private: takes a precomputed hash of headers and returns the Headers
214193
# customized for the request.
215194
#
216195
# Returns a hash of header names / values valid for a given request.
217-
def use_cached_headers(default_headers, request)
196+
def use_cached_headers(headers, request)
218197
header_classes_for(request).each_with_object({}) do |klass, hash|
219-
if default_header = default_headers[klass::CONFIG_KEY]
198+
if header = headers[klass::CONFIG_KEY]
220199
header_name, value = if klass == CSP
221-
default_csp_header_for_ua(default_header, request)
200+
csp_header_for_ua(header, request)
222201
else
223-
default_header
202+
header
224203
end
225204
hash[header_name] = value
226205
end
@@ -232,13 +211,8 @@ def use_cached_headers(default_headers, request)
232211
# headers - a hash of header_config_key => [header_name, header_value]
233212
#
234213
# Returns a CSP [header, value] array
235-
def default_csp_header_for_ua(headers, request)
236-
family = UserAgent.parse(request.user_agent).browser
237-
if CSP::VARIATIONS.key?(family)
238-
headers[family]
239-
else
240-
headers[CSP::OTHER]
241-
end
214+
def csp_header_for_ua(headers, request)
215+
headers[CSP.ua_to_variation(UserAgent.parse(request.user_agent))]
242216
end
243217

244218
# Private: optionally build a header with a given configure

lib/secure_headers/configuration.rb

Lines changed: 85 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ class Configuration
33
DEFAULT_CONFIG = :default
44
NOOP_CONFIGURATION = "secure_headers_noop_config"
55
class NotYetConfiguredError < StandardError; end
6+
class IllegalPolicyModificationError < StandardError; end
67
class << self
78
# Public: Set the global default configuration.
89
#
@@ -23,12 +24,12 @@ def default(&block)
2324
# if no value is supplied.
2425
#
2526
# Returns: the newly created config
26-
def override(name, base = DEFAULT_CONFIG)
27+
def override(name, base = DEFAULT_CONFIG, &block)
2728
unless get(base)
2829
raise NotYetConfiguredError, "#{base} policy not yet supplied"
2930
end
3031
override = @configurations[base].dup
31-
yield(override)
32+
override.instance_eval &block if block_given?
3233
add_configuration(name, override)
3334
end
3435

@@ -43,18 +44,6 @@ def get(name = DEFAULT_CONFIG)
4344
@configurations[name]
4445
end
4546

46-
# Public: perform a basic deep dup. The shallow copy provided by dup/clone
47-
# can lead to modifying parent objects.
48-
def deep_copy(config)
49-
config.each_with_object({}) do |(key, value), hash|
50-
hash[key] = if value.is_a?(Array)
51-
value.dup
52-
else
53-
value
54-
end
55-
end
56-
end
57-
5847
private
5948

6049
# Private: add a valid configuration to the global set of named configs.
@@ -86,16 +75,39 @@ def add_noop_configuration
8675

8776
add_configuration(NOOP_CONFIGURATION, noop_config)
8877
end
78+
79+
# Public: perform a basic deep dup. The shallow copy provided by dup/clone
80+
# can lead to modifying parent objects.
81+
def deep_copy(config)
82+
config.each_with_object({}) do |(key, value), hash|
83+
hash[key] = if value.is_a?(Array)
84+
value.dup
85+
else
86+
value
87+
end
88+
end
89+
end
90+
91+
# Private: convenience method purely DRY things up. The value may not be a
92+
# hash (e.g. OPT_OUT, nil)
93+
def deep_copy_if_hash(value)
94+
if value.is_a?(Hash)
95+
deep_copy(value)
96+
else
97+
value
98+
end
99+
end
89100
end
90101

91-
attr_accessor :hsts, :x_frame_options, :x_content_type_options,
92-
:x_xss_protection, :csp, :x_download_options, :x_permitted_cross_domain_policies,
93-
:hpkp, :secure_cookies
94-
attr_reader :cached_headers
102+
attr_writer :hsts, :x_frame_options, :x_content_type_options,
103+
:x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
104+
:hpkp, :dynamic_csp, :secure_cookies
105+
106+
attr_reader :cached_headers, :csp, :dynamic_csp, :secure_cookies
95107

96108
def initialize(&block)
97109
self.hpkp = OPT_OUT
98-
self.csp = self.class.deep_copy(CSP::DEFAULT_CONFIG)
110+
self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG)
99111
instance_eval &block if block_given?
100112
end
101113

@@ -104,33 +116,37 @@ def initialize(&block)
104116
# Returns a deep-dup'd copy of this configuration.
105117
def dup
106118
copy = self.class.new
107-
copy.hsts = hsts
108-
copy.x_frame_options = x_frame_options
109-
copy.x_content_type_options = x_content_type_options
110-
copy.x_xss_protection = x_xss_protection
111-
copy.x_download_options = x_download_options
112-
copy.x_permitted_cross_domain_policies = x_permitted_cross_domain_policies
113-
copy.csp = if csp.is_a?(Hash)
114-
self.class.deep_copy(csp)
115-
else
116-
csp
119+
copy.secure_cookies = @secure_cookies
120+
copy.csp = self.class.send(:deep_copy_if_hash, @csp)
121+
copy.dynamic_csp = self.class.send(:deep_copy_if_hash, @dynamic_csp)
122+
copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
123+
copy
124+
end
125+
126+
def opt_out(header)
127+
send("#{header}=", OPT_OUT)
128+
if header == CSP::CONFIG_KEY
129+
dynamic_csp = OPT_OUT
117130
end
131+
self.cached_headers.delete(header)
132+
end
133+
134+
def update_x_frame_options(value)
135+
self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
136+
end
118137

119-
copy.hpkp = if hpkp.is_a?(Hash)
120-
self.class.deep_copy(hpkp)
121-
else
122-
hpkp
138+
# Public: generated cached headers for a specific user agent.
139+
def rebuild_csp_header_cache!(user_agent)
140+
self.cached_headers[CSP::CONFIG_KEY] = {}
141+
unless current_csp == OPT_OUT
142+
user_agent = UserAgent.parse(user_agent)
143+
variation = CSP.ua_to_variation(user_agent)
144+
self.cached_headers[CSP::CONFIG_KEY][variation] = CSP.make_header(current_csp, user_agent)
123145
end
124-
copy
125146
end
126147

127-
# Public: Retrieve a config based on the CONFIG_KEY for a class
128-
#
129-
# Returns the value if available, and returns a dup of any hash values.
130-
def fetch(key)
131-
config = send(key)
132-
config = self.class.deep_copy(config) if config.is_a?(Hash)
133-
config
148+
def current_csp
149+
@dynamic_csp || @csp
134150
end
135151

136152
# Public: validates all configurations values.
@@ -139,24 +155,40 @@ def fetch(key)
139155
#
140156
# Returns nothing
141157
def validate_config!
142-
StrictTransportSecurity.validate_config!(hsts)
143-
ContentSecurityPolicy.validate_config!(csp)
144-
XFrameOptions.validate_config!(x_frame_options)
145-
XContentTypeOptions.validate_config!(x_content_type_options)
146-
XXssProtection.validate_config!(x_xss_protection)
147-
XDownloadOptions.validate_config!(x_download_options)
148-
XPermittedCrossDomainPolicies.validate_config!(x_permitted_cross_domain_policies)
149-
PublicKeyPins.validate_config!(hpkp)
158+
StrictTransportSecurity.validate_config!(@hsts)
159+
ContentSecurityPolicy.validate_config!(@csp)
160+
XFrameOptions.validate_config!(@x_frame_options)
161+
XContentTypeOptions.validate_config!(@x_content_type_options)
162+
XXssProtection.validate_config!(@x_xss_protection)
163+
XDownloadOptions.validate_config!(@x_download_options)
164+
XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
165+
PublicKeyPins.validate_config!(@hpkp)
150166
end
151167

168+
protected
169+
170+
def csp=(new_csp)
171+
if self.dynamic_csp
172+
raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= isntead."
173+
end
174+
175+
@csp = new_csp
176+
end
177+
178+
def cached_headers=(headers)
179+
@cached_headers = headers
180+
end
181+
182+
private
183+
152184
# Public: Precompute the header names and values for this configuraiton.
153185
# Ensures that headers generated at configure time, not on demand.
154186
#
155187
# Returns the cached headers
156188
def cache_headers!
157189
# generate defaults for the "easy" headers
158190
headers = (ALL_HEADERS_BESIDES_CSP).each_with_object({}) do |klass, hash|
159-
config = fetch(klass::CONFIG_KEY)
191+
config = instance_variable_get("@#{klass::CONFIG_KEY}")
160192
unless config == OPT_OUT
161193
hash[klass::CONFIG_KEY] = klass.make_header(config).freeze
162194
end
@@ -165,7 +197,7 @@ def cache_headers!
165197
generate_csp_headers(headers)
166198

167199
headers.freeze
168-
@cached_headers = headers
200+
self.cached_headers = headers
169201
end
170202

171203
# Private: adds CSP headers for each variation of CSP support.
@@ -175,11 +207,10 @@ def cache_headers!
175207
#
176208
# Returns nothing
177209
def generate_csp_headers(headers)
178-
unless csp == OPT_OUT
210+
unless @csp == OPT_OUT
179211
headers[CSP::CONFIG_KEY] = {}
180-
212+
csp_config = self.current_csp
181213
CSP::VARIATIONS.each do |name, _|
182-
csp_config = fetch(CSP::CONFIG_KEY)
183214
csp = CSP.make_header(csp_config, UserAgent.parse(name))
184215
headers[CSP::CONFIG_KEY][name] = csp.freeze
185216
end

lib/secure_headers/headers/policy_management.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def validate_config!(config)
197197
# because google.com is already in the config.
198198
def idempotent_additions?(config, additions)
199199
return false if config == OPT_OUT
200-
config.to_s == combine_policies(config, additions).to_s
200+
config == combine_policies(config, additions)
201201
end
202202

203203
# Public: combine the values from two different configs.
@@ -218,11 +218,19 @@ def combine_policies(original, additions)
218218
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
219219
end
220220

221-
original = original.dup if original.frozen?
221+
original = Configuration.send(:deep_copy, original)
222222
populate_fetch_source_with_default!(original, additions)
223223
merge_policy_additions(original, additions)
224224
end
225225

226+
def ua_to_variation(user_agent)
227+
if family = user_agent.browser && VARIATIONS.key?(family)
228+
VARIATIONS[family]
229+
else
230+
OTHER
231+
end
232+
end
233+
226234
private
227235

228236
# merge the two hashes. combine (instead of overwrite) the array values

0 commit comments

Comments
 (0)