secure_headers 3.2.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/.github/ISSUE_TEMPLATE.md +41 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- data/.rspec +2 -0
- data/.rubocop.yml +3 -0
- data/.ruby-version +1 -1
- data/.travis.yml +8 -4
- data/CHANGELOG.md +144 -1
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +9 -4
- data/Guardfile +1 -0
- data/LICENSE +4 -199
- data/README.md +73 -344
- 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 +116 -42
- 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 +128 -39
- 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 +158 -68
- data/lib/secure_headers/headers/public_key_pins.rb +5 -4
- data/lib/secure_headers/headers/referrer_policy.rb +37 -0
- 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 +16 -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 +15 -8
- data/lib/secure_headers.rb +139 -55
- data/lib/tasks/tasks.rake +7 -5
- 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 +89 -15
- 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 +80 -42
- data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +73 -0
- 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 +55 -17
- data/spec/lib/secure_headers/view_helpers_spec.rb +21 -8
- data/spec/lib/secure_headers_spec.rb +330 -58
- data/spec/spec_helper.rb +17 -25
- data/upgrading-to-3-0.md +13 -10
- data/upgrading-to-4-0.md +55 -0
- metadata +34 -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,24 +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
|
|
|
82
|
+
EDGE_DIRECTIVES = DIRECTIVES_1_0
|
|
94
83
|
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
84
|
+
SAFARI_10_DIRECTIVES = DIRECTIVES_2_0
|
|
95
85
|
|
|
96
86
|
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
97
87
|
BLOCK_ALL_MIXED_CONTENT,
|
|
@@ -99,15 +89,28 @@ module SecureHeaders
|
|
|
99
89
|
PLUGIN_TYPES
|
|
100
90
|
].freeze
|
|
101
91
|
|
|
92
|
+
FIREFOX_46_DEPRECATED_DIRECTIVES = [
|
|
93
|
+
FRAME_SRC
|
|
94
|
+
].freeze
|
|
95
|
+
|
|
96
|
+
FIREFOX_46_UNSUPPORTED_DIRECTIVES = [
|
|
97
|
+
BLOCK_ALL_MIXED_CONTENT,
|
|
98
|
+
PLUGIN_TYPES
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
102
101
|
FIREFOX_DIRECTIVES = (
|
|
103
|
-
|
|
102
|
+
DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
103
|
+
).freeze
|
|
104
|
+
|
|
105
|
+
FIREFOX_46_DIRECTIVES = (
|
|
106
|
+
DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
|
|
104
107
|
).freeze
|
|
105
108
|
|
|
106
109
|
CHROME_DIRECTIVES = (
|
|
107
|
-
|
|
110
|
+
DIRECTIVES_3_0
|
|
108
111
|
).freeze
|
|
109
112
|
|
|
110
|
-
ALL_DIRECTIVES =
|
|
113
|
+
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
|
|
111
114
|
|
|
112
115
|
# Think of default-src and report-uri as the beginning and end respectively,
|
|
113
116
|
# everything else is in between.
|
|
@@ -117,7 +120,10 @@ module SecureHeaders
|
|
|
117
120
|
"Chrome" => CHROME_DIRECTIVES,
|
|
118
121
|
"Opera" => CHROME_DIRECTIVES,
|
|
119
122
|
"Firefox" => FIREFOX_DIRECTIVES,
|
|
123
|
+
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
|
|
120
124
|
"Safari" => SAFARI_DIRECTIVES,
|
|
125
|
+
"SafariTransitional" => SAFARI_10_DIRECTIVES,
|
|
126
|
+
"Edge" => EDGE_DIRECTIVES,
|
|
121
127
|
"Other" => CHROME_DIRECTIVES
|
|
122
128
|
}.freeze
|
|
123
129
|
|
|
@@ -137,16 +143,31 @@ module SecureHeaders
|
|
|
137
143
|
MANIFEST_SRC => :source_list,
|
|
138
144
|
MEDIA_SRC => :source_list,
|
|
139
145
|
OBJECT_SRC => :source_list,
|
|
140
|
-
PLUGIN_TYPES => :
|
|
141
|
-
REFLECTED_XSS => :string,
|
|
146
|
+
PLUGIN_TYPES => :media_type_list,
|
|
142
147
|
REPORT_URI => :source_list,
|
|
143
|
-
SANDBOX => :
|
|
148
|
+
SANDBOX => :sandbox_list,
|
|
144
149
|
SCRIPT_SRC => :source_list,
|
|
145
150
|
STYLE_SRC => :source_list,
|
|
146
151
|
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
147
152
|
}.freeze
|
|
148
153
|
|
|
149
|
-
|
|
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
|
+
|
|
150
171
|
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
151
172
|
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
152
173
|
|
|
@@ -163,6 +184,11 @@ module SecureHeaders
|
|
|
163
184
|
:preserve_schemes
|
|
164
185
|
].freeze
|
|
165
186
|
|
|
187
|
+
NONCES = [
|
|
188
|
+
:script_nonce,
|
|
189
|
+
:style_nonce
|
|
190
|
+
].freeze
|
|
191
|
+
|
|
166
192
|
module ClassMethods
|
|
167
193
|
# Public: generate a header name, value array that is user-agent-aware.
|
|
168
194
|
#
|
|
@@ -178,9 +204,15 @@ module SecureHeaders
|
|
|
178
204
|
# Does not validate the invididual values of the source expression (e.g.
|
|
179
205
|
# script_src => h*t*t*p: will not raise an exception)
|
|
180
206
|
def validate_config!(config)
|
|
181
|
-
return if config.nil? || config
|
|
182
|
-
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config
|
|
183
|
-
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
|
|
184
216
|
if META_CONFIGS.include?(key)
|
|
185
217
|
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
|
186
218
|
else
|
|
@@ -189,16 +221,13 @@ module SecureHeaders
|
|
|
189
221
|
end
|
|
190
222
|
end
|
|
191
223
|
|
|
192
|
-
# Public:
|
|
193
|
-
# actual value of the config.
|
|
224
|
+
# Public: check if a user agent supports CSP nonces
|
|
194
225
|
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return false if config == OPT_OUT
|
|
201
|
-
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
|
|
202
231
|
end
|
|
203
232
|
|
|
204
233
|
# Public: combine the values from two different configs.
|
|
@@ -215,7 +244,7 @@ module SecureHeaders
|
|
|
215
244
|
# 3. if a value in additions does exist in the original config, the two
|
|
216
245
|
# values are joined.
|
|
217
246
|
def combine_policies(original, additions)
|
|
218
|
-
if original ==
|
|
247
|
+
if original == {}
|
|
219
248
|
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
|
220
249
|
end
|
|
221
250
|
|
|
@@ -239,7 +268,7 @@ module SecureHeaders
|
|
|
239
268
|
# when each hash contains a value for a given key.
|
|
240
269
|
def merge_policy_additions(original, additions)
|
|
241
270
|
original.merge(additions) do |directive, lhs, rhs|
|
|
242
|
-
if
|
|
271
|
+
if list_directive?(directive)
|
|
243
272
|
(lhs.to_a + rhs.to_a).compact.uniq
|
|
244
273
|
else
|
|
245
274
|
rhs
|
|
@@ -247,48 +276,108 @@ module SecureHeaders
|
|
|
247
276
|
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
248
277
|
end
|
|
249
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
|
+
|
|
250
286
|
# For each directive in additions that does not exist in the original config,
|
|
251
287
|
# copy the default-src value to the original config. This modifies the original hash.
|
|
252
288
|
def populate_fetch_source_with_default!(original, additions)
|
|
253
289
|
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
254
|
-
additions.
|
|
255
|
-
|
|
256
|
-
|
|
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)
|
|
257
300
|
end
|
|
258
301
|
end
|
|
259
302
|
end
|
|
260
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
|
+
|
|
261
310
|
def source_list?(directive)
|
|
262
311
|
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
263
312
|
end
|
|
264
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
|
+
|
|
265
322
|
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
266
323
|
# source expression.
|
|
267
|
-
def validate_directive!(directive,
|
|
324
|
+
def validate_directive!(directive, value)
|
|
325
|
+
ensure_valid_directive!(directive)
|
|
268
326
|
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
|
|
269
327
|
when :boolean
|
|
270
|
-
unless boolean?(
|
|
271
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean value")
|
|
272
|
-
end
|
|
273
|
-
when :string
|
|
274
|
-
unless source_expression.is_a?(String)
|
|
275
|
-
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")
|
|
276
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)
|
|
277
337
|
else
|
|
278
|
-
|
|
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)")
|
|
279
368
|
end
|
|
280
369
|
end
|
|
281
370
|
|
|
282
371
|
# Private: validates that a source expression:
|
|
283
|
-
# 1.
|
|
284
|
-
# 2.
|
|
285
|
-
# 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)
|
|
286
374
|
#
|
|
287
375
|
# Does not validate the invididual values of the source expression (e.g.
|
|
288
376
|
# script_src => h*t*t*p: will not raise an exception)
|
|
289
377
|
def validate_source_expression!(directive, source_expression)
|
|
290
|
-
|
|
291
|
-
|
|
378
|
+
if source_expression != OPT_OUT
|
|
379
|
+
ensure_array_of_strings!(directive, source_expression)
|
|
380
|
+
end
|
|
292
381
|
ensure_valid_sources!(directive, source_expression)
|
|
293
382
|
end
|
|
294
383
|
|
|
@@ -298,16 +387,17 @@ module SecureHeaders
|
|
|
298
387
|
end
|
|
299
388
|
end
|
|
300
389
|
|
|
301
|
-
def ensure_array_of_strings!(directive,
|
|
302
|
-
|
|
390
|
+
def ensure_array_of_strings!(directive, value)
|
|
391
|
+
if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
|
|
303
392
|
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
|
|
304
393
|
end
|
|
305
394
|
end
|
|
306
395
|
|
|
307
396
|
def ensure_valid_sources!(directive, source_expression)
|
|
308
|
-
source_expression
|
|
309
|
-
|
|
310
|
-
|
|
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.")
|
|
311
401
|
end
|
|
312
402
|
end
|
|
313
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
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class ReferrerPolicyConfigError < StandardError; end
|
|
4
|
+
class ReferrerPolicy
|
|
5
|
+
HEADER_NAME = "Referrer-Policy".freeze
|
|
6
|
+
DEFAULT_VALUE = "origin-when-cross-origin"
|
|
7
|
+
VALID_POLICIES = %w(
|
|
8
|
+
no-referrer
|
|
9
|
+
no-referrer-when-downgrade
|
|
10
|
+
same-origin
|
|
11
|
+
strict-origin
|
|
12
|
+
strict-origin-when-cross-origin
|
|
13
|
+
origin
|
|
14
|
+
origin-when-cross-origin
|
|
15
|
+
unsafe-url
|
|
16
|
+
)
|
|
17
|
+
CONFIG_KEY = :referrer_policy
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Public: generate an Referrer Policy header.
|
|
21
|
+
#
|
|
22
|
+
# Returns a default header if no configuration is provided, or a
|
|
23
|
+
# header name and value based on the config.
|
|
24
|
+
def make_header(config = nil)
|
|
25
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_config!(config)
|
|
29
|
+
return if config.nil? || config == OPT_OUT
|
|
30
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
31
|
+
unless VALID_POLICIES.include?(config.downcase)
|
|
32
|
+
raise ReferrerPolicyConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -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,5 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
class Middleware
|
|
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"
|
|
5
|
+
|
|
3
6
|
def initialize(app)
|
|
4
7
|
@app = app
|
|
5
8
|
end
|
|
@@ -10,6 +13,10 @@ module SecureHeaders
|
|
|
10
13
|
status, headers, response = @app.call(env)
|
|
11
14
|
|
|
12
15
|
config = SecureHeaders.config_for(req)
|
|
16
|
+
if config.hpkp_report_host == req.host
|
|
17
|
+
Kernel.warn(HPKP_SAME_HOST_WARNING)
|
|
18
|
+
end
|
|
19
|
+
|
|
13
20
|
flag_cookies!(headers, override_secure(env, config.cookies)) if config.cookies
|
|
14
21
|
headers.merge!(SecureHeaders.header_hash_for(req))
|
|
15
22
|
[status, headers, response]
|
|
@@ -19,11 +26,11 @@ module SecureHeaders
|
|
|
19
26
|
|
|
20
27
|
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
21
28
|
def flag_cookies!(headers, config)
|
|
22
|
-
if cookies = headers[
|
|
29
|
+
if cookies = headers["Set-Cookie"]
|
|
23
30
|
# Support Rails 2.3 / Rack 1.1 arrays as headers
|
|
24
31
|
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
25
32
|
|
|
26
|
-
headers[
|
|
33
|
+
headers["Set-Cookie"] = cookies.map do |cookie|
|
|
27
34
|
SecureHeaders::Cookie.new(cookie, config).to_s
|
|
28
35
|
end.join("\n")
|
|
29
36
|
end
|
|
@@ -31,8 +38,8 @@ module SecureHeaders
|
|
|
31
38
|
|
|
32
39
|
# disable Secure cookies for non-https requests
|
|
33
40
|
def override_secure(env, config = {})
|
|
34
|
-
if scheme(env) !=
|
|
35
|
-
config
|
|
41
|
+
if scheme(env) != "https" && config != OPT_OUT
|
|
42
|
+
config[:secure] = OPT_OUT
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
config
|
|
@@ -40,12 +47,12 @@ module SecureHeaders
|
|
|
40
47
|
|
|
41
48
|
# derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
|
|
42
49
|
def scheme(env)
|
|
43
|
-
if env[
|
|
44
|
-
|
|
45
|
-
elsif env[
|
|
46
|
-
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]
|
|
47
54
|
else
|
|
48
|
-
env[
|
|
55
|
+
env["rack.url_scheme"]
|
|
49
56
|
end
|
|
50
57
|
end
|
|
51
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
|