secure_headers 3.0.3 → 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/.gitignore +0 -9
- data/.rspec +2 -0
- data/.rubocop.yml +3 -0
- data/.ruby-version +1 -1
- data/.travis.yml +17 -5
- data/CHANGELOG.md +287 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +13 -5
- data/Guardfile +1 -0
- data/LICENSE +4 -199
- data/README.md +85 -265
- 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 +188 -59
- data/lib/secure_headers/hash_helper.rb +11 -0
- data/lib/secure_headers/headers/clear_site_data.rb +55 -0
- data/lib/secure_headers/headers/content_security_policy.rb +136 -325
- data/lib/secure_headers/headers/content_security_policy_config.rb +162 -0
- data/lib/secure_headers/headers/cookie.rb +144 -0
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
- data/lib/secure_headers/headers/policy_management.rb +410 -0
- 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 +3 -2
- 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 +44 -0
- data/lib/secure_headers/railtie.rb +11 -6
- data/lib/secure_headers/utils/cookies_config.rb +95 -0
- data/lib/secure_headers/view_helper.rb +76 -5
- data/lib/secure_headers.rb +159 -99
- data/lib/tasks/tasks.rake +83 -0
- data/secure_headers.gemspec +13 -3
- data/spec/lib/secure_headers/configuration_spec.rb +24 -9
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +87 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +88 -189
- data/spec/lib/secure_headers/headers/cookie_spec.rb +182 -0
- data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +228 -0
- 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 +6 -5
- 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 +96 -6
- data/spec/lib/secure_headers/view_helpers_spec.rb +138 -0
- data/spec/lib/secure_headers_spec.rb +407 -58
- data/spec/spec_helper.rb +19 -12
- data/upgrading-to-3-0.md +13 -9
- data/upgrading-to-4-0.md +55 -0
- metadata +45 -9
- data/lib/secure_headers/padrino.rb +0 -13
- data/travis.sh +0 -10
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
module PolicyManagement
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.extend(ClassMethods)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
MODERN_BROWSERS = %w(Chrome Opera Firefox)
|
|
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
|
|
17
|
+
DATA_PROTOCOL = "data:".freeze
|
|
18
|
+
BLOB_PROTOCOL = "blob:".freeze
|
|
19
|
+
SELF = "'self'".freeze
|
|
20
|
+
NONE = "'none'".freeze
|
|
21
|
+
STAR = "*".freeze
|
|
22
|
+
UNSAFE_INLINE = "'unsafe-inline'".freeze
|
|
23
|
+
UNSAFE_EVAL = "'unsafe-eval'".freeze
|
|
24
|
+
STRICT_DYNAMIC = "'strict-dynamic'".freeze
|
|
25
|
+
|
|
26
|
+
# leftover deprecated values that will be in common use upon upgrading.
|
|
27
|
+
DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
|
|
28
|
+
|
|
29
|
+
DEFAULT_SRC = :default_src
|
|
30
|
+
CONNECT_SRC = :connect_src
|
|
31
|
+
FONT_SRC = :font_src
|
|
32
|
+
FRAME_SRC = :frame_src
|
|
33
|
+
IMG_SRC = :img_src
|
|
34
|
+
MEDIA_SRC = :media_src
|
|
35
|
+
OBJECT_SRC = :object_src
|
|
36
|
+
SANDBOX = :sandbox
|
|
37
|
+
SCRIPT_SRC = :script_src
|
|
38
|
+
STYLE_SRC = :style_src
|
|
39
|
+
REPORT_URI = :report_uri
|
|
40
|
+
|
|
41
|
+
DIRECTIVES_1_0 = [
|
|
42
|
+
DEFAULT_SRC,
|
|
43
|
+
CONNECT_SRC,
|
|
44
|
+
FONT_SRC,
|
|
45
|
+
FRAME_SRC,
|
|
46
|
+
IMG_SRC,
|
|
47
|
+
MEDIA_SRC,
|
|
48
|
+
OBJECT_SRC,
|
|
49
|
+
SANDBOX,
|
|
50
|
+
SCRIPT_SRC,
|
|
51
|
+
STYLE_SRC,
|
|
52
|
+
REPORT_URI
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
BASE_URI = :base_uri
|
|
56
|
+
CHILD_SRC = :child_src
|
|
57
|
+
FORM_ACTION = :form_action
|
|
58
|
+
FRAME_ANCESTORS = :frame_ancestors
|
|
59
|
+
PLUGIN_TYPES = :plugin_types
|
|
60
|
+
|
|
61
|
+
DIRECTIVES_2_0 = [
|
|
62
|
+
DIRECTIVES_1_0,
|
|
63
|
+
BASE_URI,
|
|
64
|
+
CHILD_SRC,
|
|
65
|
+
FORM_ACTION,
|
|
66
|
+
FRAME_ANCESTORS,
|
|
67
|
+
PLUGIN_TYPES
|
|
68
|
+
].flatten.freeze
|
|
69
|
+
|
|
70
|
+
# All the directives currently under consideration for CSP level 3.
|
|
71
|
+
# https://w3c.github.io/webappsec/specs/CSP2/
|
|
72
|
+
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
73
|
+
MANIFEST_SRC = :manifest_src
|
|
74
|
+
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
75
|
+
DIRECTIVES_3_0 = [
|
|
76
|
+
DIRECTIVES_2_0,
|
|
77
|
+
BLOCK_ALL_MIXED_CONTENT,
|
|
78
|
+
MANIFEST_SRC,
|
|
79
|
+
UPGRADE_INSECURE_REQUESTS
|
|
80
|
+
].flatten.freeze
|
|
81
|
+
|
|
82
|
+
EDGE_DIRECTIVES = DIRECTIVES_1_0
|
|
83
|
+
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
84
|
+
SAFARI_10_DIRECTIVES = DIRECTIVES_2_0
|
|
85
|
+
|
|
86
|
+
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
87
|
+
BLOCK_ALL_MIXED_CONTENT,
|
|
88
|
+
CHILD_SRC,
|
|
89
|
+
PLUGIN_TYPES
|
|
90
|
+
].freeze
|
|
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
|
+
|
|
101
|
+
FIREFOX_DIRECTIVES = (
|
|
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
|
|
107
|
+
).freeze
|
|
108
|
+
|
|
109
|
+
CHROME_DIRECTIVES = (
|
|
110
|
+
DIRECTIVES_3_0
|
|
111
|
+
).freeze
|
|
112
|
+
|
|
113
|
+
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
|
|
114
|
+
|
|
115
|
+
# Think of default-src and report-uri as the beginning and end respectively,
|
|
116
|
+
# everything else is in between.
|
|
117
|
+
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
118
|
+
|
|
119
|
+
VARIATIONS = {
|
|
120
|
+
"Chrome" => CHROME_DIRECTIVES,
|
|
121
|
+
"Opera" => CHROME_DIRECTIVES,
|
|
122
|
+
"Firefox" => FIREFOX_DIRECTIVES,
|
|
123
|
+
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
|
|
124
|
+
"Safari" => SAFARI_DIRECTIVES,
|
|
125
|
+
"SafariTransitional" => SAFARI_10_DIRECTIVES,
|
|
126
|
+
"Edge" => EDGE_DIRECTIVES,
|
|
127
|
+
"Other" => CHROME_DIRECTIVES
|
|
128
|
+
}.freeze
|
|
129
|
+
|
|
130
|
+
OTHER = "Other".freeze
|
|
131
|
+
|
|
132
|
+
DIRECTIVE_VALUE_TYPES = {
|
|
133
|
+
BASE_URI => :source_list,
|
|
134
|
+
BLOCK_ALL_MIXED_CONTENT => :boolean,
|
|
135
|
+
CHILD_SRC => :source_list,
|
|
136
|
+
CONNECT_SRC => :source_list,
|
|
137
|
+
DEFAULT_SRC => :source_list,
|
|
138
|
+
FONT_SRC => :source_list,
|
|
139
|
+
FORM_ACTION => :source_list,
|
|
140
|
+
FRAME_ANCESTORS => :source_list,
|
|
141
|
+
FRAME_SRC => :source_list,
|
|
142
|
+
IMG_SRC => :source_list,
|
|
143
|
+
MANIFEST_SRC => :source_list,
|
|
144
|
+
MEDIA_SRC => :source_list,
|
|
145
|
+
OBJECT_SRC => :source_list,
|
|
146
|
+
PLUGIN_TYPES => :media_type_list,
|
|
147
|
+
REPORT_URI => :source_list,
|
|
148
|
+
SANDBOX => :sandbox_list,
|
|
149
|
+
SCRIPT_SRC => :source_list,
|
|
150
|
+
STYLE_SRC => :source_list,
|
|
151
|
+
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
152
|
+
}.freeze
|
|
153
|
+
|
|
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
|
+
|
|
171
|
+
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
172
|
+
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
173
|
+
|
|
174
|
+
WILDCARD_SOURCES = [
|
|
175
|
+
UNSAFE_EVAL,
|
|
176
|
+
UNSAFE_INLINE,
|
|
177
|
+
STAR,
|
|
178
|
+
DATA_PROTOCOL,
|
|
179
|
+
BLOB_PROTOCOL
|
|
180
|
+
].freeze
|
|
181
|
+
|
|
182
|
+
META_CONFIGS = [
|
|
183
|
+
:report_only,
|
|
184
|
+
:preserve_schemes
|
|
185
|
+
].freeze
|
|
186
|
+
|
|
187
|
+
NONCES = [
|
|
188
|
+
:script_nonce,
|
|
189
|
+
:style_nonce
|
|
190
|
+
].freeze
|
|
191
|
+
|
|
192
|
+
module ClassMethods
|
|
193
|
+
# Public: generate a header name, value array that is user-agent-aware.
|
|
194
|
+
#
|
|
195
|
+
# Returns a default policy if no configuration is provided, or a
|
|
196
|
+
# header name and value based on the config.
|
|
197
|
+
def make_header(config, user_agent)
|
|
198
|
+
header = new(config, user_agent)
|
|
199
|
+
[header.name, header.value]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Public: Validates each source expression.
|
|
203
|
+
#
|
|
204
|
+
# Does not validate the invididual values of the source expression (e.g.
|
|
205
|
+
# script_src => h*t*t*p: will not raise an exception)
|
|
206
|
+
def validate_config!(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
|
|
216
|
+
if META_CONFIGS.include?(key)
|
|
217
|
+
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
|
218
|
+
else
|
|
219
|
+
validate_directive!(key, value)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Public: check if a user agent supports CSP nonces
|
|
225
|
+
#
|
|
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
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Public: combine the values from two different configs.
|
|
234
|
+
#
|
|
235
|
+
# original - the main config
|
|
236
|
+
# additions - values to be merged in
|
|
237
|
+
#
|
|
238
|
+
# raises an error if the original config is OPT_OUT
|
|
239
|
+
#
|
|
240
|
+
# 1. for non-source-list values (report_only, block_all_mixed_content, upgrade_insecure_requests),
|
|
241
|
+
# additions will overwrite the original value.
|
|
242
|
+
# 2. if a value in additions does not exist in the original config, the
|
|
243
|
+
# default-src value is included to match original behavior.
|
|
244
|
+
# 3. if a value in additions does exist in the original config, the two
|
|
245
|
+
# values are joined.
|
|
246
|
+
def combine_policies(original, additions)
|
|
247
|
+
if original == {}
|
|
248
|
+
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
original = Configuration.send(:deep_copy, original)
|
|
252
|
+
populate_fetch_source_with_default!(original, additions)
|
|
253
|
+
merge_policy_additions(original, additions)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def ua_to_variation(user_agent)
|
|
257
|
+
family = user_agent.browser
|
|
258
|
+
if family && VARIATIONS.key?(family)
|
|
259
|
+
family
|
|
260
|
+
else
|
|
261
|
+
OTHER
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
# merge the two hashes. combine (instead of overwrite) the array values
|
|
268
|
+
# when each hash contains a value for a given key.
|
|
269
|
+
def merge_policy_additions(original, additions)
|
|
270
|
+
original.merge(additions) do |directive, lhs, rhs|
|
|
271
|
+
if list_directive?(directive)
|
|
272
|
+
(lhs.to_a + rhs.to_a).compact.uniq
|
|
273
|
+
else
|
|
274
|
+
rhs
|
|
275
|
+
end
|
|
276
|
+
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
277
|
+
end
|
|
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
|
+
|
|
286
|
+
# For each directive in additions that does not exist in the original config,
|
|
287
|
+
# copy the default-src value to the original config. This modifies the original hash.
|
|
288
|
+
def populate_fetch_source_with_default!(original, additions)
|
|
289
|
+
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
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)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
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
|
+
|
|
310
|
+
def source_list?(directive)
|
|
311
|
+
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
312
|
+
end
|
|
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
|
+
|
|
322
|
+
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
323
|
+
# source expression.
|
|
324
|
+
def validate_directive!(directive, value)
|
|
325
|
+
ensure_valid_directive!(directive)
|
|
326
|
+
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
|
|
327
|
+
when :boolean
|
|
328
|
+
unless boolean?(value)
|
|
329
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
|
|
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)
|
|
337
|
+
else
|
|
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)")
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Private: validates that a source expression:
|
|
372
|
+
# 1. is an array of strings
|
|
373
|
+
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
|
|
374
|
+
#
|
|
375
|
+
# Does not validate the invididual values of the source expression (e.g.
|
|
376
|
+
# script_src => h*t*t*p: will not raise an exception)
|
|
377
|
+
def validate_source_expression!(directive, source_expression)
|
|
378
|
+
if source_expression != OPT_OUT
|
|
379
|
+
ensure_array_of_strings!(directive, source_expression)
|
|
380
|
+
end
|
|
381
|
+
ensure_valid_sources!(directive, source_expression)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def ensure_valid_directive!(directive)
|
|
385
|
+
unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(directive)
|
|
386
|
+
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def ensure_array_of_strings!(directive, value)
|
|
391
|
+
if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) })
|
|
392
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def ensure_valid_sources!(directive, source_expression)
|
|
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.")
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def boolean?(source_expression)
|
|
406
|
+
source_expression.is_a?(TrueClass) || source_expression.is_a?(FalseClass)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
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
|
|
@@ -8,8 +11,49 @@ module SecureHeaders
|
|
|
8
11
|
def call(env)
|
|
9
12
|
req = Rack::Request.new(env)
|
|
10
13
|
status, headers, response = @app.call(env)
|
|
14
|
+
|
|
15
|
+
config = SecureHeaders.config_for(req)
|
|
16
|
+
if config.hpkp_report_host == req.host
|
|
17
|
+
Kernel.warn(HPKP_SAME_HOST_WARNING)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
flag_cookies!(headers, override_secure(env, config.cookies)) if config.cookies
|
|
11
21
|
headers.merge!(SecureHeaders.header_hash_for(req))
|
|
12
22
|
[status, headers, response]
|
|
13
23
|
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
28
|
+
def flag_cookies!(headers, config)
|
|
29
|
+
if cookies = headers["Set-Cookie"]
|
|
30
|
+
# Support Rails 2.3 / Rack 1.1 arrays as headers
|
|
31
|
+
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
headers["Set-Cookie"] = cookies.map do |cookie|
|
|
34
|
+
SecureHeaders::Cookie.new(cookie, config).to_s
|
|
35
|
+
end.join("\n")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# disable Secure cookies for non-https requests
|
|
40
|
+
def override_secure(env, config = {})
|
|
41
|
+
if scheme(env) != "https" && config != OPT_OUT
|
|
42
|
+
config[:secure] = OPT_OUT
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
config
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
|
|
49
|
+
def scheme(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]
|
|
54
|
+
else
|
|
55
|
+
env["rack.url_scheme"]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
14
58
|
end
|
|
15
59
|
end
|
|
@@ -1,16 +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
|
-
Rails.application.config.middleware.
|
|
14
|
+
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
rake_tasks do
|
|
18
|
+
load File.expand_path(File.join("..", "..", "lib", "tasks", "tasks.rake"), File.dirname(__FILE__))
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
initializer "secure_headers.action_controller" do
|