secure_headers 3.4.0 → 4.0.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/.rspec +2 -0
- data/.rubocop.yml +3 -0
- data/.ruby-version +1 -1
- data/.travis.yml +8 -4
- data/CHANGELOG.md +113 -1
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +8 -4
- data/Guardfile +1 -0
- data/LICENSE +4 -199
- data/README.md +63 -333
- data/Rakefile +22 -18
- data/docs/HPKP.md +17 -0
- data/docs/cookies.md +64 -0
- data/docs/hashes.md +64 -0
- data/docs/named_overrides_and_appends.md +107 -0
- data/docs/per_action_configuration.md +133 -0
- data/docs/sinatra.md +25 -0
- data/lib/secure_headers/configuration.rb +91 -44
- data/lib/secure_headers/hash_helper.rb +2 -1
- data/lib/secure_headers/headers/clear_site_data.rb +55 -0
- data/lib/secure_headers/headers/content_security_policy.rb +110 -51
- data/lib/secure_headers/headers/content_security_policy_config.rb +162 -0
- data/lib/secure_headers/headers/cookie.rb +21 -3
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
- data/lib/secure_headers/headers/policy_management.rb +143 -69
- data/lib/secure_headers/headers/public_key_pins.rb +5 -4
- data/lib/secure_headers/headers/referrer_policy.rb +5 -1
- data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
- data/lib/secure_headers/headers/x_content_type_options.rb +2 -1
- data/lib/secure_headers/headers/x_download_options.rb +3 -2
- data/lib/secure_headers/headers/x_frame_options.rb +2 -1
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
- data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
- data/lib/secure_headers/middleware.rb +10 -9
- data/lib/secure_headers/railtie.rb +7 -6
- data/lib/secure_headers/utils/cookies_config.rb +13 -12
- data/lib/secure_headers/view_helper.rb +12 -3
- data/lib/secure_headers.rb +137 -55
- data/lib/tasks/tasks.rake +2 -1
- data/secure_headers.gemspec +13 -3
- data/spec/lib/secure_headers/configuration_spec.rb +13 -12
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +87 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +59 -26
- data/spec/lib/secure_headers/headers/cookie_spec.rb +38 -20
- data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +78 -40
- data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +22 -3
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +5 -4
- data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
- data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
- data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
- data/spec/lib/secure_headers/middleware_spec.rb +39 -19
- data/spec/lib/secure_headers/view_helpers_spec.rb +21 -8
- data/spec/lib/secure_headers_spec.rb +304 -56
- data/spec/spec_helper.rb +13 -24
- data/upgrading-to-4-0.md +55 -0
- metadata +29 -7
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
module PolicyManagement
|
|
3
4
|
def self.included(base)
|
|
@@ -5,11 +6,14 @@ module SecureHeaders
|
|
|
5
6
|
end
|
|
6
7
|
|
|
7
8
|
MODERN_BROWSERS = %w(Chrome Opera Firefox)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
DEFAULT_CONFIG = {
|
|
10
|
+
default_src: %w(https:),
|
|
11
|
+
img_src: %w(https: data: 'self'),
|
|
12
|
+
object_src: %w('none'),
|
|
13
|
+
script_src: %w(https:),
|
|
14
|
+
style_src: %w('self' 'unsafe-inline' https:),
|
|
15
|
+
form_action: %w('self')
|
|
16
|
+
}.freeze
|
|
13
17
|
DATA_PROTOCOL = "data:".freeze
|
|
14
18
|
BLOB_PROTOCOL = "blob:".freeze
|
|
15
19
|
SELF = "'self'".freeze
|
|
@@ -17,6 +21,7 @@ module SecureHeaders
|
|
|
17
21
|
STAR = "*".freeze
|
|
18
22
|
UNSAFE_INLINE = "'unsafe-inline'".freeze
|
|
19
23
|
UNSAFE_EVAL = "'unsafe-eval'".freeze
|
|
24
|
+
STRICT_DYNAMIC = "'strict-dynamic'".freeze
|
|
20
25
|
|
|
21
26
|
# leftover deprecated values that will be in common use upon upgrading.
|
|
22
27
|
DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
|
|
@@ -53,16 +58,6 @@ module SecureHeaders
|
|
|
53
58
|
FRAME_ANCESTORS = :frame_ancestors
|
|
54
59
|
PLUGIN_TYPES = :plugin_types
|
|
55
60
|
|
|
56
|
-
# These are directives that do not inherit the default-src value. This is
|
|
57
|
-
# useful when calling #combine_policies.
|
|
58
|
-
NON_FETCH_SOURCES = [
|
|
59
|
-
BASE_URI,
|
|
60
|
-
FORM_ACTION,
|
|
61
|
-
FRAME_ANCESTORS,
|
|
62
|
-
PLUGIN_TYPES,
|
|
63
|
-
REPORT_URI
|
|
64
|
-
]
|
|
65
|
-
|
|
66
61
|
DIRECTIVES_2_0 = [
|
|
67
62
|
DIRECTIVES_1_0,
|
|
68
63
|
BASE_URI,
|
|
@@ -74,25 +69,19 @@ module SecureHeaders
|
|
|
74
69
|
|
|
75
70
|
# All the directives currently under consideration for CSP level 3.
|
|
76
71
|
# https://w3c.github.io/webappsec/specs/CSP2/
|
|
72
|
+
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
77
73
|
MANIFEST_SRC = :manifest_src
|
|
78
|
-
|
|
74
|
+
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
79
75
|
DIRECTIVES_3_0 = [
|
|
80
76
|
DIRECTIVES_2_0,
|
|
81
|
-
MANIFEST_SRC,
|
|
82
|
-
REFLECTED_XSS
|
|
83
|
-
].flatten.freeze
|
|
84
|
-
|
|
85
|
-
# All the directives that are not currently in a formal spec, but have
|
|
86
|
-
# been implemented somewhere.
|
|
87
|
-
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
88
|
-
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
89
|
-
DIRECTIVES_DRAFT = [
|
|
90
77
|
BLOCK_ALL_MIXED_CONTENT,
|
|
78
|
+
MANIFEST_SRC,
|
|
91
79
|
UPGRADE_INSECURE_REQUESTS
|
|
92
|
-
].freeze
|
|
80
|
+
].flatten.freeze
|
|
93
81
|
|
|
94
82
|
EDGE_DIRECTIVES = DIRECTIVES_1_0
|
|
95
83
|
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
84
|
+
SAFARI_10_DIRECTIVES = DIRECTIVES_2_0
|
|
96
85
|
|
|
97
86
|
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
98
87
|
BLOCK_ALL_MIXED_CONTENT,
|
|
@@ -110,18 +99,18 @@ module SecureHeaders
|
|
|
110
99
|
].freeze
|
|
111
100
|
|
|
112
101
|
FIREFOX_DIRECTIVES = (
|
|
113
|
-
|
|
102
|
+
DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
114
103
|
).freeze
|
|
115
104
|
|
|
116
105
|
FIREFOX_46_DIRECTIVES = (
|
|
117
|
-
|
|
106
|
+
DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
|
|
118
107
|
).freeze
|
|
119
108
|
|
|
120
109
|
CHROME_DIRECTIVES = (
|
|
121
|
-
|
|
110
|
+
DIRECTIVES_3_0
|
|
122
111
|
).freeze
|
|
123
112
|
|
|
124
|
-
ALL_DIRECTIVES =
|
|
113
|
+
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
|
|
125
114
|
|
|
126
115
|
# Think of default-src and report-uri as the beginning and end respectively,
|
|
127
116
|
# everything else is in between.
|
|
@@ -133,6 +122,7 @@ module SecureHeaders
|
|
|
133
122
|
"Firefox" => FIREFOX_DIRECTIVES,
|
|
134
123
|
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
|
|
135
124
|
"Safari" => SAFARI_DIRECTIVES,
|
|
125
|
+
"SafariTransitional" => SAFARI_10_DIRECTIVES,
|
|
136
126
|
"Edge" => EDGE_DIRECTIVES,
|
|
137
127
|
"Other" => CHROME_DIRECTIVES
|
|
138
128
|
}.freeze
|
|
@@ -153,16 +143,31 @@ module SecureHeaders
|
|
|
153
143
|
MANIFEST_SRC => :source_list,
|
|
154
144
|
MEDIA_SRC => :source_list,
|
|
155
145
|
OBJECT_SRC => :source_list,
|
|
156
|
-
PLUGIN_TYPES => :
|
|
157
|
-
REFLECTED_XSS => :string,
|
|
146
|
+
PLUGIN_TYPES => :media_type_list,
|
|
158
147
|
REPORT_URI => :source_list,
|
|
159
|
-
SANDBOX => :
|
|
148
|
+
SANDBOX => :sandbox_list,
|
|
160
149
|
SCRIPT_SRC => :source_list,
|
|
161
150
|
STYLE_SRC => :source_list,
|
|
162
151
|
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
163
152
|
}.freeze
|
|
164
153
|
|
|
165
|
-
|
|
154
|
+
# These are directives that don't have use a source list, and hence do not
|
|
155
|
+
# inherit the default-src value.
|
|
156
|
+
NON_SOURCE_LIST_SOURCES = DIRECTIVE_VALUE_TYPES.select do |_, type|
|
|
157
|
+
type != :source_list
|
|
158
|
+
end.keys.freeze
|
|
159
|
+
|
|
160
|
+
# These are directives that take a source list, but that do not inherit
|
|
161
|
+
# the default-src value.
|
|
162
|
+
NON_FETCH_SOURCES = [
|
|
163
|
+
BASE_URI,
|
|
164
|
+
FORM_ACTION,
|
|
165
|
+
FRAME_ANCESTORS,
|
|
166
|
+
REPORT_URI
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES - NON_SOURCE_LIST_SOURCES
|
|
170
|
+
|
|
166
171
|
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
167
172
|
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
168
173
|
|
|
@@ -179,6 +184,11 @@ module SecureHeaders
|
|
|
179
184
|
:preserve_schemes
|
|
180
185
|
].freeze
|
|
181
186
|
|
|
187
|
+
NONCES = [
|
|
188
|
+
:script_nonce,
|
|
189
|
+
:style_nonce
|
|
190
|
+
].freeze
|
|
191
|
+
|
|
182
192
|
module ClassMethods
|
|
183
193
|
# Public: generate a header name, value array that is user-agent-aware.
|
|
184
194
|
#
|
|
@@ -194,9 +204,15 @@ module SecureHeaders
|
|
|
194
204
|
# Does not validate the invididual values of the source expression (e.g.
|
|
195
205
|
# script_src => h*t*t*p: will not raise an exception)
|
|
196
206
|
def validate_config!(config)
|
|
197
|
-
return if config.nil? || config
|
|
198
|
-
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config
|
|
199
|
-
config.
|
|
207
|
+
return if config.nil? || config.opt_out?
|
|
208
|
+
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src)
|
|
209
|
+
if config.directive_value(:script_src).nil?
|
|
210
|
+
raise ContentSecurityPolicyConfigError.new(":script_src is required, falling back to default-src is too dangerous. Use `script_src: OPT_OUT` to override")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
ContentSecurityPolicyConfig.attrs.each do |key|
|
|
214
|
+
value = config.directive_value(key)
|
|
215
|
+
next unless value
|
|
200
216
|
if META_CONFIGS.include?(key)
|
|
201
217
|
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
|
202
218
|
else
|
|
@@ -205,16 +221,13 @@ module SecureHeaders
|
|
|
205
221
|
end
|
|
206
222
|
end
|
|
207
223
|
|
|
208
|
-
# Public:
|
|
209
|
-
# actual value of the config.
|
|
224
|
+
# Public: check if a user agent supports CSP nonces
|
|
210
225
|
#
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return false if config == OPT_OUT
|
|
217
|
-
config == combine_policies(config, additions)
|
|
226
|
+
# user_agent - a String or a UserAgent object
|
|
227
|
+
def nonces_supported?(user_agent)
|
|
228
|
+
user_agent = UserAgent.parse(user_agent) if user_agent.is_a?(String)
|
|
229
|
+
MODERN_BROWSERS.include?(user_agent.browser) ||
|
|
230
|
+
user_agent.browser == "Safari" && (user_agent.version || CSP::FALLBACK_VERSION) >= CSP::VERSION_10
|
|
218
231
|
end
|
|
219
232
|
|
|
220
233
|
# Public: combine the values from two different configs.
|
|
@@ -231,7 +244,7 @@ module SecureHeaders
|
|
|
231
244
|
# 3. if a value in additions does exist in the original config, the two
|
|
232
245
|
# values are joined.
|
|
233
246
|
def combine_policies(original, additions)
|
|
234
|
-
if original ==
|
|
247
|
+
if original == {}
|
|
235
248
|
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
|
236
249
|
end
|
|
237
250
|
|
|
@@ -255,7 +268,7 @@ module SecureHeaders
|
|
|
255
268
|
# when each hash contains a value for a given key.
|
|
256
269
|
def merge_policy_additions(original, additions)
|
|
257
270
|
original.merge(additions) do |directive, lhs, rhs|
|
|
258
|
-
if
|
|
271
|
+
if list_directive?(directive)
|
|
259
272
|
(lhs.to_a + rhs.to_a).compact.uniq
|
|
260
273
|
else
|
|
261
274
|
rhs
|
|
@@ -263,48 +276,108 @@ module SecureHeaders
|
|
|
263
276
|
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
264
277
|
end
|
|
265
278
|
|
|
279
|
+
# Returns True if a directive expects a list of values and False otherwise.
|
|
280
|
+
def list_directive?(directive)
|
|
281
|
+
source_list?(directive) ||
|
|
282
|
+
sandbox_list?(directive) ||
|
|
283
|
+
media_type_list?(directive)
|
|
284
|
+
end
|
|
285
|
+
|
|
266
286
|
# For each directive in additions that does not exist in the original config,
|
|
267
287
|
# copy the default-src value to the original config. This modifies the original hash.
|
|
268
288
|
def populate_fetch_source_with_default!(original, additions)
|
|
269
289
|
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
270
|
-
additions.
|
|
271
|
-
|
|
272
|
-
|
|
290
|
+
additions.each_key do |directive|
|
|
291
|
+
directive = if directive.to_s.end_with?("_nonce")
|
|
292
|
+
directive.to_s.gsub(/_nonce/, "_src").to_sym
|
|
293
|
+
else
|
|
294
|
+
directive
|
|
295
|
+
end
|
|
296
|
+
# Don't set a default if directive has an existing value
|
|
297
|
+
next if original[directive]
|
|
298
|
+
if FETCH_SOURCES.include?(directive)
|
|
299
|
+
original[directive] = default_for(directive, original)
|
|
273
300
|
end
|
|
274
301
|
end
|
|
275
302
|
end
|
|
276
303
|
|
|
304
|
+
def default_for(directive, original)
|
|
305
|
+
return original[FRAME_SRC] if directive == CHILD_SRC && original[FRAME_SRC]
|
|
306
|
+
return original[CHILD_SRC] if directive == FRAME_SRC && original[CHILD_SRC]
|
|
307
|
+
original[DEFAULT_SRC]
|
|
308
|
+
end
|
|
309
|
+
|
|
277
310
|
def source_list?(directive)
|
|
278
311
|
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
279
312
|
end
|
|
280
313
|
|
|
314
|
+
def sandbox_list?(directive)
|
|
315
|
+
DIRECTIVE_VALUE_TYPES[directive] == :sandbox_list
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def media_type_list?(directive)
|
|
319
|
+
DIRECTIVE_VALUE_TYPES[directive] == :media_type_list
|
|
320
|
+
end
|
|
321
|
+
|
|
281
322
|
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
282
323
|
# source expression.
|
|
283
|
-
def validate_directive!(directive,
|
|
324
|
+
def validate_directive!(directive, value)
|
|
325
|
+
ensure_valid_directive!(directive)
|
|
284
326
|
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
|
|
285
327
|
when :boolean
|
|
286
|
-
unless boolean?(
|
|
287
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean value")
|
|
288
|
-
end
|
|
289
|
-
when :string
|
|
290
|
-
unless source_expression.is_a?(String)
|
|
291
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} Must be a string. Found #{config.class}: #{config} value")
|
|
328
|
+
unless boolean?(value)
|
|
329
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
|
|
292
330
|
end
|
|
331
|
+
when :sandbox_list
|
|
332
|
+
validate_sandbox_expression!(directive, value)
|
|
333
|
+
when :media_type_list
|
|
334
|
+
validate_media_type_expression!(directive, value)
|
|
335
|
+
when :source_list
|
|
336
|
+
validate_source_expression!(directive, value)
|
|
293
337
|
else
|
|
294
|
-
|
|
338
|
+
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Private: validates that a sandbox token expression:
|
|
343
|
+
# 1. is an array of strings or optionally `true` (to enable maximal sandboxing)
|
|
344
|
+
# 2. For arrays, each element is of the form allow-*
|
|
345
|
+
def validate_sandbox_expression!(directive, sandbox_token_expression)
|
|
346
|
+
# We support sandbox: true to indicate a maximally secure sandbox.
|
|
347
|
+
return if boolean?(sandbox_token_expression) && sandbox_token_expression == true
|
|
348
|
+
ensure_array_of_strings!(directive, sandbox_token_expression)
|
|
349
|
+
valid = sandbox_token_expression.compact.all? do |v|
|
|
350
|
+
v.is_a?(String) && v.start_with?("allow-")
|
|
351
|
+
end
|
|
352
|
+
if !valid
|
|
353
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be True or an array of zero or more sandbox token strings (ex. allow-forms)")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Private: validates that a media type expression:
|
|
358
|
+
# 1. is an array of strings
|
|
359
|
+
# 2. each element is of the form type/subtype
|
|
360
|
+
def validate_media_type_expression!(directive, media_type_expression)
|
|
361
|
+
ensure_array_of_strings!(directive, media_type_expression)
|
|
362
|
+
valid = media_type_expression.compact.all? do |v|
|
|
363
|
+
# All media types are of the form: <type from RFC 2045> "/" <subtype from RFC 2045>.
|
|
364
|
+
v =~ /\A.+\/.+\z/
|
|
365
|
+
end
|
|
366
|
+
if !valid
|
|
367
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of valid media types (ex. application/pdf)")
|
|
295
368
|
end
|
|
296
369
|
end
|
|
297
370
|
|
|
298
371
|
# Private: validates that a source expression:
|
|
299
|
-
# 1.
|
|
300
|
-
# 2.
|
|
301
|
-
# 3. does not contain any depreated, now invalid values (inline, eval, self, none)
|
|
372
|
+
# 1. is an array of strings
|
|
373
|
+
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
|
|
302
374
|
#
|
|
303
375
|
# Does not validate the invididual values of the source expression (e.g.
|
|
304
376
|
# script_src => h*t*t*p: will not raise an exception)
|
|
305
377
|
def validate_source_expression!(directive, source_expression)
|
|
306
|
-
|
|
307
|
-
|
|
378
|
+
if source_expression != OPT_OUT
|
|
379
|
+
ensure_array_of_strings!(directive, source_expression)
|
|
380
|
+
end
|
|
308
381
|
ensure_valid_sources!(directive, source_expression)
|
|
309
382
|
end
|
|
310
383
|
|
|
@@ -314,16 +387,17 @@ module SecureHeaders
|
|
|
314
387
|
end
|
|
315
388
|
end
|
|
316
389
|
|
|
317
|
-
def ensure_array_of_strings!(directive,
|
|
318
|
-
|
|
390
|
+
def ensure_array_of_strings!(directive, value)
|
|
391
|
+
if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
|
|
319
392
|
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
|
|
320
393
|
end
|
|
321
394
|
end
|
|
322
395
|
|
|
323
396
|
def ensure_valid_sources!(directive, source_expression)
|
|
324
|
-
source_expression
|
|
325
|
-
|
|
326
|
-
|
|
397
|
+
return if source_expression == OPT_OUT
|
|
398
|
+
source_expression.each do |expression|
|
|
399
|
+
if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(expression)
|
|
400
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{expression}). This value must be single quoted.")
|
|
327
401
|
end
|
|
328
402
|
end
|
|
329
403
|
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class PublicKeyPinsConfigError < StandardError; end
|
|
3
4
|
class PublicKeyPins
|
|
@@ -49,12 +50,12 @@ module SecureHeaders
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def value
|
|
52
|
-
|
|
53
|
+
[
|
|
53
54
|
max_age_directive,
|
|
54
55
|
pin_directives,
|
|
55
56
|
report_uri_directive,
|
|
56
57
|
subdomain_directive
|
|
57
|
-
].compact.join(
|
|
58
|
+
].compact.join("; ").strip
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
def pin_directives
|
|
@@ -63,7 +64,7 @@ module SecureHeaders
|
|
|
63
64
|
pin.map do |token, hash|
|
|
64
65
|
"pin-#{token}=\"#{hash}\"" if HASH_ALGORITHMS.include?(token)
|
|
65
66
|
end
|
|
66
|
-
end.join(
|
|
67
|
+
end.join("; ")
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def max_age_directive
|
|
@@ -75,7 +76,7 @@ module SecureHeaders
|
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
def subdomain_directive
|
|
78
|
-
@include_subdomains ?
|
|
79
|
+
@include_subdomains ? "includeSubDomains" : nil
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
end
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class ReferrerPolicyConfigError < StandardError; end
|
|
3
4
|
class ReferrerPolicy
|
|
4
|
-
HEADER_NAME = "Referrer-Policy"
|
|
5
|
+
HEADER_NAME = "Referrer-Policy".freeze
|
|
5
6
|
DEFAULT_VALUE = "origin-when-cross-origin"
|
|
6
7
|
VALID_POLICIES = %w(
|
|
7
8
|
no-referrer
|
|
8
9
|
no-referrer-when-downgrade
|
|
10
|
+
same-origin
|
|
11
|
+
strict-origin
|
|
12
|
+
strict-origin-when-cross-origin
|
|
9
13
|
origin
|
|
10
14
|
origin-when-cross-origin
|
|
11
15
|
unsafe-url
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class STSConfigError < StandardError; end
|
|
3
4
|
|
|
4
5
|
class StrictTransportSecurity
|
|
5
|
-
HEADER_NAME =
|
|
6
|
+
HEADER_NAME = "Strict-Transport-Security".freeze
|
|
6
7
|
HSTS_MAX_AGE = "631138519"
|
|
7
8
|
DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE
|
|
8
9
|
VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XContentTypeOptionsConfigError < StandardError; end
|
|
3
4
|
|
|
4
5
|
class XContentTypeOptions
|
|
5
|
-
HEADER_NAME = "X-Content-Type-Options"
|
|
6
|
+
HEADER_NAME = "X-Content-Type-Options".freeze
|
|
6
7
|
DEFAULT_VALUE = "nosniff"
|
|
7
8
|
CONFIG_KEY = :x_content_type_options
|
|
8
9
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XDOConfigError < StandardError; end
|
|
3
4
|
class XDownloadOptions
|
|
4
|
-
HEADER_NAME = "X-Download-Options"
|
|
5
|
-
DEFAULT_VALUE =
|
|
5
|
+
HEADER_NAME = "X-Download-Options".freeze
|
|
6
|
+
DEFAULT_VALUE = "noopen"
|
|
6
7
|
CONFIG_KEY = :x_download_options
|
|
7
8
|
|
|
8
9
|
class << self
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XFOConfigError < StandardError; end
|
|
3
4
|
class XFrameOptions
|
|
4
|
-
HEADER_NAME = "X-Frame-Options"
|
|
5
|
+
HEADER_NAME = "X-Frame-Options".freeze
|
|
5
6
|
CONFIG_KEY = :x_frame_options
|
|
6
7
|
SAMEORIGIN = "sameorigin"
|
|
7
8
|
DENY = "deny"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XPCDPConfigError < StandardError; end
|
|
3
4
|
class XPermittedCrossDomainPolicies
|
|
4
|
-
HEADER_NAME = "X-Permitted-Cross-Domain-Policies"
|
|
5
|
-
DEFAULT_VALUE =
|
|
5
|
+
HEADER_NAME = "X-Permitted-Cross-Domain-Policies".freeze
|
|
6
|
+
DEFAULT_VALUE = "none"
|
|
6
7
|
VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename)
|
|
7
8
|
CONFIG_KEY = :x_permitted_cross_domain_policies
|
|
8
9
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class XXssProtectionConfigError < StandardError; end
|
|
3
4
|
class XXssProtection
|
|
4
|
-
HEADER_NAME =
|
|
5
|
+
HEADER_NAME = "X-XSS-Protection".freeze
|
|
5
6
|
DEFAULT_VALUE = "1; mode=block"
|
|
6
7
|
VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/i
|
|
7
8
|
CONFIG_KEY = :x_xss_protection
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class Middleware
|
|
3
4
|
HPKP_SAME_HOST_WARNING = "[WARNING] HPKP report host should not be the same as the request host. See https://github.com/twitter/secureheaders/issues/166"
|
|
@@ -25,11 +26,11 @@ module SecureHeaders
|
|
|
25
26
|
|
|
26
27
|
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
27
28
|
def flag_cookies!(headers, config)
|
|
28
|
-
if cookies = headers[
|
|
29
|
+
if cookies = headers["Set-Cookie"]
|
|
29
30
|
# Support Rails 2.3 / Rack 1.1 arrays as headers
|
|
30
31
|
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
31
32
|
|
|
32
|
-
headers[
|
|
33
|
+
headers["Set-Cookie"] = cookies.map do |cookie|
|
|
33
34
|
SecureHeaders::Cookie.new(cookie, config).to_s
|
|
34
35
|
end.join("\n")
|
|
35
36
|
end
|
|
@@ -37,8 +38,8 @@ module SecureHeaders
|
|
|
37
38
|
|
|
38
39
|
# disable Secure cookies for non-https requests
|
|
39
40
|
def override_secure(env, config = {})
|
|
40
|
-
if scheme(env) !=
|
|
41
|
-
config
|
|
41
|
+
if scheme(env) != "https" && config != OPT_OUT
|
|
42
|
+
config[:secure] = OPT_OUT
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
config
|
|
@@ -46,12 +47,12 @@ module SecureHeaders
|
|
|
46
47
|
|
|
47
48
|
# derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
|
|
48
49
|
def scheme(env)
|
|
49
|
-
if env[
|
|
50
|
-
|
|
51
|
-
elsif env[
|
|
52
|
-
env[
|
|
50
|
+
if env["HTTPS"] == "on" || env["HTTP_X_SSL_REQUEST"] == "on"
|
|
51
|
+
"https"
|
|
52
|
+
elsif env["HTTP_X_FORWARDED_PROTO"]
|
|
53
|
+
env["HTTP_X_FORWARDED_PROTO"].split(",")[0]
|
|
53
54
|
else
|
|
54
|
-
env[
|
|
55
|
+
env["rack.url_scheme"]
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
58
|
end
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
# rails 3.1+
|
|
2
3
|
if defined?(Rails::Railtie)
|
|
3
4
|
module SecureHeaders
|
|
4
5
|
class Railtie < Rails::Railtie
|
|
5
6
|
isolate_namespace SecureHeaders if defined? isolate_namespace # rails 3.0
|
|
6
|
-
conflicting_headers = [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
conflicting_headers = ["X-Frame-Options", "X-XSS-Protection",
|
|
8
|
+
"X-Permitted-Cross-Domain-Policies", "X-Download-Options",
|
|
9
|
+
"X-Content-Type-Options", "Strict-Transport-Security",
|
|
10
|
+
"Content-Security-Policy", "Content-Security-Policy-Report-Only",
|
|
11
|
+
"Public-Key-Pins", "Public-Key-Pins-Report-Only", "Referrer-Policy"]
|
|
11
12
|
|
|
12
13
|
initializer "secure_headers.middleware" do
|
|
13
14
|
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
rake_tasks do
|
|
17
|
-
load File.expand_path(File.join(
|
|
18
|
+
load File.expand_path(File.join("..", "..", "lib", "tasks", "tasks.rake"), File.dirname(__FILE__))
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
initializer "secure_headers.action_controller" do
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class CookiesConfig
|
|
3
4
|
|
|
@@ -11,9 +12,9 @@ module SecureHeaders
|
|
|
11
12
|
return if config.nil? || config == SecureHeaders::OPT_OUT
|
|
12
13
|
|
|
13
14
|
validate_config!
|
|
14
|
-
validate_secure_config!
|
|
15
|
-
validate_httponly_config!
|
|
16
|
-
validate_samesite_config!
|
|
15
|
+
validate_secure_config! unless config[:secure].nil?
|
|
16
|
+
validate_httponly_config! unless config[:httponly].nil?
|
|
17
|
+
validate_samesite_config! unless config[:samesite].nil?
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
@@ -23,16 +24,17 @@ module SecureHeaders
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def validate_secure_config!
|
|
26
|
-
|
|
27
|
+
validate_hash_or_true_or_opt_out!(:secure)
|
|
27
28
|
validate_exclusive_use_of_hash_constraints!(config[:secure], :secure)
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def validate_httponly_config!
|
|
31
|
-
|
|
32
|
+
validate_hash_or_true_or_opt_out!(:httponly)
|
|
32
33
|
validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def validate_samesite_config!
|
|
37
|
+
return if config[:samesite] == OPT_OUT
|
|
36
38
|
raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite])
|
|
37
39
|
|
|
38
40
|
validate_samesite_boolean_config!
|
|
@@ -51,18 +53,18 @@ module SecureHeaders
|
|
|
51
53
|
def validate_samesite_hash_config!
|
|
52
54
|
# validate Hash-based samesite configuration
|
|
53
55
|
if is_hash?(config[:samesite][:lax])
|
|
54
|
-
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax],
|
|
56
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], "samesite lax")
|
|
55
57
|
|
|
56
58
|
if is_hash?(config[:samesite][:strict])
|
|
57
|
-
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict],
|
|
59
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], "samesite strict")
|
|
58
60
|
validate_exclusive_use_of_samesite_enforcement!(:only)
|
|
59
61
|
validate_exclusive_use_of_samesite_enforcement!(:except)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
def
|
|
65
|
-
if !(is_hash?(config[attribute]) ||
|
|
66
|
+
def validate_hash_or_true_or_opt_out!(attribute)
|
|
67
|
+
if !(is_hash?(config[attribute]) || is_true_or_opt_out?(config[attribute]))
|
|
66
68
|
raise CookiesConfigError.new("#{attribute} cookie config must be a hash or boolean")
|
|
67
69
|
end
|
|
68
70
|
end
|
|
@@ -70,7 +72,6 @@ module SecureHeaders
|
|
|
70
72
|
# validate exclusive use of only or except but not both at the same time
|
|
71
73
|
def validate_exclusive_use_of_hash_constraints!(conf, attribute)
|
|
72
74
|
return unless is_hash?(conf)
|
|
73
|
-
|
|
74
75
|
if conf.key?(:only) && conf.key?(:except)
|
|
75
76
|
raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.")
|
|
76
77
|
end
|
|
@@ -87,8 +88,8 @@ module SecureHeaders
|
|
|
87
88
|
obj && obj.is_a?(Hash)
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
def
|
|
91
|
-
obj && (obj.is_a?(TrueClass) || obj
|
|
91
|
+
def is_true_or_opt_out?(obj)
|
|
92
|
+
obj && (obj.is_a?(TrueClass) || obj == OPT_OUT)
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
end
|