Skip to content

Commit d300d6f

Browse files
committed
Remove caching and make overrides work as expected
We ran into a bug where overrides weren't working as expected. The user expectation is that an override should override a config value for whatever the current configuration for the current request. But, as implemented, they actually were just forks of a base config..regardless of whether that base config changed during runtime. So, overrides no work that way and we don't currently support any way of using an alternate config. This might be added back later if it is useful. While making the changes related to overrides it became obvious that caching was a huge source of complexity. So, this also removes all caching. A quick local benchmark shows that a non-cached config takes about .18ms to build out a set of headers, so it shouldn't be a big concern.
1 parent c7fc44c commit d300d6f

18 files changed

+113
-268
lines changed

lib/secure_headers.rb

Lines changed: 12 additions & 71 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,35 +148,25 @@ 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
172151
user_agent = UserAgent.parse(request.user_agent)
152+
headers = config.generate_headers(user_agent)
173153

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
154+
if request.scheme != HTTPS
155+
HTTPS_HEADER_CLASSES.each do |klass|
156+
headers.delete(klass::HEADER_NAME)
190157
end
191158
end
159+
headers
192160
end
193161

194162
# Public: specify which named override will be used for this request.
195163
# Raises an argument error if no named override exists.
196164
#
197165
# name - the name of the previously configured override.
198166
def use_secure_headers_override(request, name)
199-
if config = Configuration.get(name)
167+
if override = Configuration.overrides(name)
168+
config = config_for(request)
169+
config.instance_eval(&override)
200170
override_secure_headers_request_config(request, config)
201171
else
202172
raise ArgumentError.new("no override by the name of #{name} has been configured")
@@ -285,35 +255,6 @@ def content_security_policy_nonce(request, script_or_style)
285255
def override_secure_headers_request_config(request, config)
286256
request.env[SECURE_HEADERS_CONFIG] = config
287257
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
317258
end
318259

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

lib/secure_headers/configuration.rb

Lines changed: 57 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
module SecureHeaders
55
class Configuration
66
DEFAULT_CONFIG = :default
7-
NOOP_CONFIGURATION = "secure_headers_noop_config"
7+
NOOP_OVERRIDE = "secure_headers_noop_override"
88
class NotYetConfiguredError < StandardError; end
99
class IllegalPolicyModificationError < StandardError; end
1010
class << self
@@ -15,8 +15,13 @@ class << self
1515
# Returns the newly created config.
1616
def default(&block)
1717
config = new(&block)
18-
add_noop_configuration
1918
add_configuration(DEFAULT_CONFIG, config)
19+
override(NOOP_OVERRIDE) do |config|
20+
CONFIG_ATTRIBUTES.each do |attr|
21+
config.instance_variable_set("@#{attr}", OPT_OUT)
22+
end
23+
end
24+
config
2025
end
2126
alias_method :configure, :default
2227

@@ -27,13 +32,14 @@ def default(&block)
2732
# if no value is supplied.
2833
#
2934
# Returns: the newly created config
30-
def override(name, base = DEFAULT_CONFIG, &block)
31-
unless get(base)
32-
raise NotYetConfiguredError, "#{base} policy not yet supplied"
33-
end
34-
override = @configurations[base].dup
35-
override.instance_eval(&block) if block_given?
36-
add_configuration(name, override)
35+
def override(name, &block)
36+
@overrides ||= {}
37+
@overrides[name] = block
38+
end
39+
40+
def overrides(name)
41+
@overrides ||= {}
42+
@overrides[name]
3743
end
3844

3945
# Public: retrieve a global configuration object
@@ -72,25 +78,10 @@ def named_append(name, target = nil, &block)
7278
def add_configuration(name, config)
7379
config.validate_config!
7480
@configurations ||= {}
75-
config.send(:cache_headers!)
76-
config.send(:cache_hpkp_report_host)
7781
config.freeze
7882
@configurations[name] = config
7983
end
8084

81-
# Private: Automatically add an "opt-out of everything" override.
82-
#
83-
# Returns the noop config
84-
def add_noop_configuration
85-
noop_config = new do |config|
86-
ALL_HEADER_CLASSES.each do |klass|
87-
config.send("#{klass::CONFIG_KEY}=", OPT_OUT)
88-
end
89-
end
90-
91-
add_configuration(NOOP_CONFIGURATION, noop_config)
92-
end
93-
9485
# Public: perform a basic deep dup. The shallow copy provided by dup/clone
9586
# can lead to modifying parent objects.
9687
def deep_copy(config)
@@ -115,11 +106,28 @@ def deep_copy_if_hash(value)
115106
end
116107
end
117108

118-
attr_writer :hsts, :x_frame_options, :x_content_type_options,
119-
:x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
120-
:referrer_policy, :clear_site_data, :expect_certificate_transparency
121-
122-
attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
109+
NON_HEADER_ATTRIBUTES = [
110+
:cookies, :hpkp_report_host
111+
].freeze
112+
113+
HEADER_ATTRIBUTES_TO_HEADER_CLASSES = {
114+
hsts: StrictTransportSecurity,
115+
x_frame_options: XFrameOptions,
116+
x_content_type_options: XContentTypeOptions,
117+
x_xss_protection: XXssProtection,
118+
x_download_options: XDownloadOptions,
119+
x_permitted_cross_domain_policies: XPermittedCrossDomainPolicies,
120+
referrer_policy: ReferrerPolicy,
121+
clear_site_data: ClearSiteData,
122+
expect_certificate_transparency: ExpectCertificateTransparency,
123+
csp: ContentSecurityPolicy,
124+
csp_report_only: ContentSecurityPolicy,
125+
hpkp: PublicKeyPins,
126+
}.freeze
127+
128+
CONFIG_ATTRIBUTES = (HEADER_ATTRIBUTES_TO_HEADER_CLASSES.keys + NON_HEADER_ATTRIBUTES).freeze
129+
130+
attr_accessor(*CONFIG_ATTRIBUTES)
123131

124132
@script_hashes = nil
125133
@style_hashes = nil
@@ -154,15 +162,14 @@ def initialize(&block)
154162
instance_eval(&block) if block_given?
155163
end
156164

157-
# Public: copy everything but the cached headers
165+
# Public: copy everything
158166
#
159167
# Returns a deep-dup'd copy of this configuration.
160168
def dup
161169
copy = self.class.new
162170
copy.cookies = self.class.send(:deep_copy_if_hash, @cookies)
163171
copy.csp = @csp.dup if @csp
164172
copy.csp_report_only = @csp_report_only.dup if @csp_report_only
165-
copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
166173
copy.x_content_type_options = @x_content_type_options
167174
copy.hsts = @hsts
168175
copy.x_frame_options = @x_frame_options
@@ -173,18 +180,26 @@ def dup
173180
copy.expect_certificate_transparency = @expect_certificate_transparency
174181
copy.referrer_policy = @referrer_policy
175182
copy.hpkp = @hpkp
176-
copy.hpkp_report_host = @hpkp_report_host
177183
copy
178184
end
179185

186+
def generate_headers(user_agent)
187+
headers = {}
188+
HEADER_ATTRIBUTES_TO_HEADER_CLASSES.each do |attr, klass|
189+
header_name, value = klass.make_header(instance_variable_get("@#{attr}"), user_agent)
190+
if header_name && value
191+
headers[header_name] = value
192+
end
193+
end
194+
headers
195+
end
196+
180197
def opt_out(header)
181198
send("#{header}=", OPT_OUT)
182-
self.cached_headers.delete(header)
183199
end
184200

185201
def update_x_frame_options(value)
186202
@x_frame_options = value
187-
self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
188203
end
189204

190205
# Public: validates all configurations values.
@@ -193,18 +208,9 @@ def update_x_frame_options(value)
193208
#
194209
# Returns nothing
195210
def validate_config!
196-
StrictTransportSecurity.validate_config!(@hsts)
197-
ContentSecurityPolicy.validate_config!(@csp)
198-
ContentSecurityPolicy.validate_config!(@csp_report_only)
199-
ReferrerPolicy.validate_config!(@referrer_policy)
200-
XFrameOptions.validate_config!(@x_frame_options)
201-
XContentTypeOptions.validate_config!(@x_content_type_options)
202-
XXssProtection.validate_config!(@x_xss_protection)
203-
XDownloadOptions.validate_config!(@x_download_options)
204-
XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
205-
ClearSiteData.validate_config!(@clear_site_data)
206-
ExpectCertificateTransparency.validate_config!(@expect_certificate_transparency)
207-
PublicKeyPins.validate_config!(@hpkp)
211+
HEADER_ATTRIBUTES_TO_HEADER_CLASSES.each do |attr, klass|
212+
klass.validate_config!(instance_variable_get("@#{attr}"))
213+
end
208214
Cookie.validate_config!(@cookies)
209215
end
210216

@@ -247,72 +253,19 @@ def csp_report_only=(new_csp)
247253
end
248254
end
249255

256+
def hpkp_report_host
257+
return nil unless @hpkp && hpkp != OPT_OUT && @hpkp[:report_uri]
258+
URI.parse(@hpkp[:report_uri]).host
259+
end
260+
250261
protected
251262

252263
def cookies=(cookies)
253264
@cookies = cookies
254265
end
255266

256-
def cached_headers=(headers)
257-
@cached_headers = headers
258-
end
259-
260267
def hpkp=(hpkp)
261268
@hpkp = self.class.send(:deep_copy_if_hash, hpkp)
262269
end
263-
264-
def hpkp_report_host=(hpkp_report_host)
265-
@hpkp_report_host = hpkp_report_host
266-
end
267-
268-
private
269-
270-
def cache_hpkp_report_host
271-
has_report_uri = @hpkp && @hpkp != OPT_OUT && @hpkp[:report_uri]
272-
self.hpkp_report_host = if has_report_uri
273-
parsed_report_uri = URI.parse(@hpkp[:report_uri])
274-
parsed_report_uri.host
275-
end
276-
end
277-
278-
# Public: Precompute the header names and values for this configuration.
279-
# Ensures that headers generated at configure time, not on demand.
280-
#
281-
# Returns the cached headers
282-
def cache_headers!
283-
# generate defaults for the "easy" headers
284-
headers = (ALL_HEADERS_BESIDES_CSP).each_with_object({}) do |klass, hash|
285-
config = instance_variable_get("@#{klass::CONFIG_KEY}")
286-
unless config == OPT_OUT
287-
hash[klass::CONFIG_KEY] = klass.make_header(config).freeze
288-
end
289-
end
290-
291-
generate_csp_headers(headers)
292-
293-
headers.freeze
294-
self.cached_headers = headers
295-
end
296-
297-
# Private: adds CSP headers for each variation of CSP support.
298-
#
299-
# headers - generated headers are added to this hash namespaced by The
300-
# different variations
301-
#
302-
# Returns nothing
303-
def generate_csp_headers(headers)
304-
generate_csp_headers_for_config(headers, ContentSecurityPolicyConfig::CONFIG_KEY, self.csp)
305-
generate_csp_headers_for_config(headers, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY, self.csp_report_only)
306-
end
307-
308-
def generate_csp_headers_for_config(headers, header_key, csp_config)
309-
unless csp_config.opt_out?
310-
headers[header_key] = {}
311-
ContentSecurityPolicy::VARIATIONS.each_key do |name|
312-
csp = ContentSecurityPolicy.make_header(csp_config, UserAgent.parse(name))
313-
headers[header_key][name] = csp.freeze
314-
end
315-
end
316-
end
317270
end
318271
end

0 commit comments

Comments
 (0)