secure_headers 3.0.3 → 3.2.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/.gitignore +0 -9
- data/.travis.yml +13 -5
- data/CHANGELOG.md +144 -0
- data/Gemfile +5 -2
- data/README.md +152 -61
- data/lib/secure_headers/configuration.rb +109 -54
- data/lib/secure_headers/hash_helper.rb +10 -0
- data/lib/secure_headers/headers/content_security_policy.rb +31 -309
- data/lib/secure_headers/headers/cookie.rb +126 -0
- data/lib/secure_headers/headers/policy_management.rb +320 -0
- data/lib/secure_headers/headers/x_content_type_options.rb +1 -1
- data/lib/secure_headers/middleware.rb +37 -0
- data/lib/secure_headers/railtie.rb +5 -1
- data/lib/secure_headers/utils/cookies_config.rb +94 -0
- data/lib/secure_headers/view_helper.rb +64 -0
- data/lib/secure_headers.rb +39 -63
- data/lib/tasks/tasks.rake +81 -0
- data/secure_headers.gemspec +1 -1
- data/spec/lib/secure_headers/configuration_spec.rb +18 -4
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +0 -175
- data/spec/lib/secure_headers/headers/cookie_spec.rb +164 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +190 -0
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +1 -1
- data/spec/lib/secure_headers/middleware_spec.rb +59 -7
- data/spec/lib/secure_headers/view_helpers_spec.rb +125 -0
- data/spec/lib/secure_headers_spec.rb +119 -42
- data/spec/spec_helper.rb +16 -1
- data/upgrading-to-3-0.md +1 -0
- metadata +13 -4
- data/lib/secure_headers/padrino.rb +0 -13
- data/travis.sh +0 -10
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
require 'secure_headers/utils/cookies_config'
|
|
3
|
+
|
|
4
|
+
module SecureHeaders
|
|
5
|
+
class CookiesConfigError < StandardError; end
|
|
6
|
+
class Cookie
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def validate_config!(config)
|
|
10
|
+
CookiesConfig.new(config).validate!
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :raw_cookie, :config
|
|
15
|
+
|
|
16
|
+
def initialize(cookie, config)
|
|
17
|
+
@raw_cookie = cookie
|
|
18
|
+
@config = config
|
|
19
|
+
@attributes = {
|
|
20
|
+
httponly: nil,
|
|
21
|
+
samesite: nil,
|
|
22
|
+
secure: nil,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
parse(cookie)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
@raw_cookie.dup.tap do |c|
|
|
30
|
+
c << "; secure" if secure?
|
|
31
|
+
c << "; HttpOnly" if httponly?
|
|
32
|
+
c << "; #{samesite_cookie}" if samesite?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def secure?
|
|
37
|
+
flag_cookie?(:secure) && !already_flagged?(:secure)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def httponly?
|
|
41
|
+
flag_cookie?(:httponly) && !already_flagged?(:httponly)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def samesite?
|
|
45
|
+
flag_samesite? && !already_flagged?(:samesite)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def parsed_cookie
|
|
51
|
+
@parsed_cookie ||= CGI::Cookie.parse(raw_cookie)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def already_flagged?(attribute)
|
|
55
|
+
@attributes[attribute]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def flag_cookie?(attribute)
|
|
59
|
+
case config[attribute]
|
|
60
|
+
when TrueClass
|
|
61
|
+
true
|
|
62
|
+
when Hash
|
|
63
|
+
conditionally_flag?(config[attribute])
|
|
64
|
+
else
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def conditionally_flag?(configuration)
|
|
70
|
+
if(Array(configuration[:only]).any? && (Array(configuration[:only]) & parsed_cookie.keys).any?)
|
|
71
|
+
true
|
|
72
|
+
elsif(Array(configuration[:except]).any? && (Array(configuration[:except]) & parsed_cookie.keys).none?)
|
|
73
|
+
true
|
|
74
|
+
else
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def samesite_cookie
|
|
80
|
+
if flag_samesite_lax?
|
|
81
|
+
"SameSite=Lax"
|
|
82
|
+
elsif flag_samesite_strict?
|
|
83
|
+
"SameSite=Strict"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flag_samesite?
|
|
88
|
+
flag_samesite_lax? || flag_samesite_strict?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def flag_samesite_lax?
|
|
92
|
+
flag_samesite_enforcement?(:lax)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def flag_samesite_strict?
|
|
96
|
+
flag_samesite_enforcement?(:strict)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def flag_samesite_enforcement?(mode)
|
|
100
|
+
return unless config[:samesite]
|
|
101
|
+
|
|
102
|
+
case config[:samesite][mode]
|
|
103
|
+
when Hash
|
|
104
|
+
conditionally_flag?(config[:samesite][mode])
|
|
105
|
+
when TrueClass
|
|
106
|
+
true
|
|
107
|
+
else
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse(cookie)
|
|
113
|
+
return unless cookie
|
|
114
|
+
|
|
115
|
+
cookie.split(/[;,]\s?/).each do |pairs|
|
|
116
|
+
name, values = pairs.split('=',2)
|
|
117
|
+
name = CGI.unescape(name)
|
|
118
|
+
|
|
119
|
+
attribute = name.downcase.to_sym
|
|
120
|
+
if @attributes.has_key?(attribute)
|
|
121
|
+
@attributes[attribute] = values || true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
module SecureHeaders
|
|
2
|
+
module PolicyManagement
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.extend(ClassMethods)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
MODERN_BROWSERS = %w(Chrome Opera Firefox)
|
|
8
|
+
DEFAULT_VALUE = "default-src https:".freeze
|
|
9
|
+
DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
|
|
10
|
+
HEADER_NAME = "Content-Security-Policy".freeze
|
|
11
|
+
REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze
|
|
12
|
+
HEADER_NAMES = [HEADER_NAME, REPORT_ONLY]
|
|
13
|
+
DATA_PROTOCOL = "data:".freeze
|
|
14
|
+
BLOB_PROTOCOL = "blob:".freeze
|
|
15
|
+
SELF = "'self'".freeze
|
|
16
|
+
NONE = "'none'".freeze
|
|
17
|
+
STAR = "*".freeze
|
|
18
|
+
UNSAFE_INLINE = "'unsafe-inline'".freeze
|
|
19
|
+
UNSAFE_EVAL = "'unsafe-eval'".freeze
|
|
20
|
+
|
|
21
|
+
# leftover deprecated values that will be in common use upon upgrading.
|
|
22
|
+
DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
|
|
23
|
+
|
|
24
|
+
DEFAULT_SRC = :default_src
|
|
25
|
+
CONNECT_SRC = :connect_src
|
|
26
|
+
FONT_SRC = :font_src
|
|
27
|
+
FRAME_SRC = :frame_src
|
|
28
|
+
IMG_SRC = :img_src
|
|
29
|
+
MEDIA_SRC = :media_src
|
|
30
|
+
OBJECT_SRC = :object_src
|
|
31
|
+
SANDBOX = :sandbox
|
|
32
|
+
SCRIPT_SRC = :script_src
|
|
33
|
+
STYLE_SRC = :style_src
|
|
34
|
+
REPORT_URI = :report_uri
|
|
35
|
+
|
|
36
|
+
DIRECTIVES_1_0 = [
|
|
37
|
+
DEFAULT_SRC,
|
|
38
|
+
CONNECT_SRC,
|
|
39
|
+
FONT_SRC,
|
|
40
|
+
FRAME_SRC,
|
|
41
|
+
IMG_SRC,
|
|
42
|
+
MEDIA_SRC,
|
|
43
|
+
OBJECT_SRC,
|
|
44
|
+
SANDBOX,
|
|
45
|
+
SCRIPT_SRC,
|
|
46
|
+
STYLE_SRC,
|
|
47
|
+
REPORT_URI
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
BASE_URI = :base_uri
|
|
51
|
+
CHILD_SRC = :child_src
|
|
52
|
+
FORM_ACTION = :form_action
|
|
53
|
+
FRAME_ANCESTORS = :frame_ancestors
|
|
54
|
+
PLUGIN_TYPES = :plugin_types
|
|
55
|
+
|
|
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
|
+
DIRECTIVES_2_0 = [
|
|
67
|
+
DIRECTIVES_1_0,
|
|
68
|
+
BASE_URI,
|
|
69
|
+
CHILD_SRC,
|
|
70
|
+
FORM_ACTION,
|
|
71
|
+
FRAME_ANCESTORS,
|
|
72
|
+
PLUGIN_TYPES
|
|
73
|
+
].flatten.freeze
|
|
74
|
+
|
|
75
|
+
# All the directives currently under consideration for CSP level 3.
|
|
76
|
+
# https://w3c.github.io/webappsec/specs/CSP2/
|
|
77
|
+
MANIFEST_SRC = :manifest_src
|
|
78
|
+
REFLECTED_XSS = :reflected_xss
|
|
79
|
+
DIRECTIVES_3_0 = [
|
|
80
|
+
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
|
+
BLOCK_ALL_MIXED_CONTENT,
|
|
91
|
+
UPGRADE_INSECURE_REQUESTS
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
95
|
+
|
|
96
|
+
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
97
|
+
BLOCK_ALL_MIXED_CONTENT,
|
|
98
|
+
CHILD_SRC,
|
|
99
|
+
PLUGIN_TYPES
|
|
100
|
+
].freeze
|
|
101
|
+
|
|
102
|
+
FIREFOX_DIRECTIVES = (
|
|
103
|
+
DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
104
|
+
).freeze
|
|
105
|
+
|
|
106
|
+
CHROME_DIRECTIVES = (
|
|
107
|
+
DIRECTIVES_2_0 + DIRECTIVES_DRAFT
|
|
108
|
+
).freeze
|
|
109
|
+
|
|
110
|
+
ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
|
|
111
|
+
|
|
112
|
+
# Think of default-src and report-uri as the beginning and end respectively,
|
|
113
|
+
# everything else is in between.
|
|
114
|
+
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
115
|
+
|
|
116
|
+
VARIATIONS = {
|
|
117
|
+
"Chrome" => CHROME_DIRECTIVES,
|
|
118
|
+
"Opera" => CHROME_DIRECTIVES,
|
|
119
|
+
"Firefox" => FIREFOX_DIRECTIVES,
|
|
120
|
+
"Safari" => SAFARI_DIRECTIVES,
|
|
121
|
+
"Other" => CHROME_DIRECTIVES
|
|
122
|
+
}.freeze
|
|
123
|
+
|
|
124
|
+
OTHER = "Other".freeze
|
|
125
|
+
|
|
126
|
+
DIRECTIVE_VALUE_TYPES = {
|
|
127
|
+
BASE_URI => :source_list,
|
|
128
|
+
BLOCK_ALL_MIXED_CONTENT => :boolean,
|
|
129
|
+
CHILD_SRC => :source_list,
|
|
130
|
+
CONNECT_SRC => :source_list,
|
|
131
|
+
DEFAULT_SRC => :source_list,
|
|
132
|
+
FONT_SRC => :source_list,
|
|
133
|
+
FORM_ACTION => :source_list,
|
|
134
|
+
FRAME_ANCESTORS => :source_list,
|
|
135
|
+
FRAME_SRC => :source_list,
|
|
136
|
+
IMG_SRC => :source_list,
|
|
137
|
+
MANIFEST_SRC => :source_list,
|
|
138
|
+
MEDIA_SRC => :source_list,
|
|
139
|
+
OBJECT_SRC => :source_list,
|
|
140
|
+
PLUGIN_TYPES => :source_list,
|
|
141
|
+
REFLECTED_XSS => :string,
|
|
142
|
+
REPORT_URI => :source_list,
|
|
143
|
+
SANDBOX => :string,
|
|
144
|
+
SCRIPT_SRC => :source_list,
|
|
145
|
+
STYLE_SRC => :source_list,
|
|
146
|
+
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
CONFIG_KEY = :csp
|
|
150
|
+
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
151
|
+
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
152
|
+
|
|
153
|
+
WILDCARD_SOURCES = [
|
|
154
|
+
UNSAFE_EVAL,
|
|
155
|
+
UNSAFE_INLINE,
|
|
156
|
+
STAR,
|
|
157
|
+
DATA_PROTOCOL,
|
|
158
|
+
BLOB_PROTOCOL
|
|
159
|
+
].freeze
|
|
160
|
+
|
|
161
|
+
META_CONFIGS = [
|
|
162
|
+
:report_only,
|
|
163
|
+
:preserve_schemes
|
|
164
|
+
].freeze
|
|
165
|
+
|
|
166
|
+
module ClassMethods
|
|
167
|
+
# Public: generate a header name, value array that is user-agent-aware.
|
|
168
|
+
#
|
|
169
|
+
# Returns a default policy if no configuration is provided, or a
|
|
170
|
+
# header name and value based on the config.
|
|
171
|
+
def make_header(config, user_agent)
|
|
172
|
+
header = new(config, user_agent)
|
|
173
|
+
[header.name, header.value]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Public: Validates each source expression.
|
|
177
|
+
#
|
|
178
|
+
# Does not validate the invididual values of the source expression (e.g.
|
|
179
|
+
# script_src => h*t*t*p: will not raise an exception)
|
|
180
|
+
def validate_config!(config)
|
|
181
|
+
return if config.nil? || config == OPT_OUT
|
|
182
|
+
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
|
|
183
|
+
config.each do |key, value|
|
|
184
|
+
if META_CONFIGS.include?(key)
|
|
185
|
+
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
|
186
|
+
else
|
|
187
|
+
validate_directive!(key, value)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Public: determine if merging +additions+ will cause a change to the
|
|
193
|
+
# actual value of the config.
|
|
194
|
+
#
|
|
195
|
+
# e.g. config = { script_src: %w(example.org google.com)} and
|
|
196
|
+
# additions = { script_src: %w(google.com)} then idempotent_additions? would return
|
|
197
|
+
# because google.com is already in the config.
|
|
198
|
+
def idempotent_additions?(config, additions)
|
|
199
|
+
return true if config == OPT_OUT && additions == OPT_OUT
|
|
200
|
+
return false if config == OPT_OUT
|
|
201
|
+
config == combine_policies(config, additions)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Public: combine the values from two different configs.
|
|
205
|
+
#
|
|
206
|
+
# original - the main config
|
|
207
|
+
# additions - values to be merged in
|
|
208
|
+
#
|
|
209
|
+
# raises an error if the original config is OPT_OUT
|
|
210
|
+
#
|
|
211
|
+
# 1. for non-source-list values (report_only, block_all_mixed_content, upgrade_insecure_requests),
|
|
212
|
+
# additions will overwrite the original value.
|
|
213
|
+
# 2. if a value in additions does not exist in the original config, the
|
|
214
|
+
# default-src value is included to match original behavior.
|
|
215
|
+
# 3. if a value in additions does exist in the original config, the two
|
|
216
|
+
# values are joined.
|
|
217
|
+
def combine_policies(original, additions)
|
|
218
|
+
if original == OPT_OUT
|
|
219
|
+
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
original = Configuration.send(:deep_copy, original)
|
|
223
|
+
populate_fetch_source_with_default!(original, additions)
|
|
224
|
+
merge_policy_additions(original, additions)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def ua_to_variation(user_agent)
|
|
228
|
+
family = user_agent.browser
|
|
229
|
+
if family && VARIATIONS.key?(family)
|
|
230
|
+
family
|
|
231
|
+
else
|
|
232
|
+
OTHER
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
# merge the two hashes. combine (instead of overwrite) the array values
|
|
239
|
+
# when each hash contains a value for a given key.
|
|
240
|
+
def merge_policy_additions(original, additions)
|
|
241
|
+
original.merge(additions) do |directive, lhs, rhs|
|
|
242
|
+
if source_list?(directive)
|
|
243
|
+
(lhs.to_a + rhs.to_a).compact.uniq
|
|
244
|
+
else
|
|
245
|
+
rhs
|
|
246
|
+
end
|
|
247
|
+
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# For each directive in additions that does not exist in the original config,
|
|
251
|
+
# copy the default-src value to the original config. This modifies the original hash.
|
|
252
|
+
def populate_fetch_source_with_default!(original, additions)
|
|
253
|
+
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
254
|
+
additions.keys.each do |directive|
|
|
255
|
+
unless original[directive] || !source_list?(directive) || NON_FETCH_SOURCES.include?(directive)
|
|
256
|
+
original[directive] = original[:default_src]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def source_list?(directive)
|
|
262
|
+
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
266
|
+
# source expression.
|
|
267
|
+
def validate_directive!(directive, source_expression)
|
|
268
|
+
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
|
|
269
|
+
when :boolean
|
|
270
|
+
unless boolean?(source_expression)
|
|
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")
|
|
276
|
+
end
|
|
277
|
+
else
|
|
278
|
+
validate_source_expression!(directive, source_expression)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Private: validates that a source expression:
|
|
283
|
+
# 1. has a valid name
|
|
284
|
+
# 2. is an array of strings
|
|
285
|
+
# 3. does not contain any depreated, now invalid values (inline, eval, self, none)
|
|
286
|
+
#
|
|
287
|
+
# Does not validate the invididual values of the source expression (e.g.
|
|
288
|
+
# script_src => h*t*t*p: will not raise an exception)
|
|
289
|
+
def validate_source_expression!(directive, source_expression)
|
|
290
|
+
ensure_valid_directive!(directive)
|
|
291
|
+
ensure_array_of_strings!(directive, source_expression)
|
|
292
|
+
ensure_valid_sources!(directive, source_expression)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def ensure_valid_directive!(directive)
|
|
296
|
+
unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(directive)
|
|
297
|
+
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def ensure_array_of_strings!(directive, source_expression)
|
|
302
|
+
unless source_expression.is_a?(Array) && source_expression.compact.all? { |v| v.is_a?(String) }
|
|
303
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def ensure_valid_sources!(directive, source_expression)
|
|
308
|
+
source_expression.each do |source_expression|
|
|
309
|
+
if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(source_expression)
|
|
310
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{source_expression}). This value must be single quoted.")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def boolean?(source_expression)
|
|
316
|
+
source_expression.is_a?(TrueClass) || source_expression.is_a?(FalseClass)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -8,8 +8,45 @@ module SecureHeaders
|
|
|
8
8
|
def call(env)
|
|
9
9
|
req = Rack::Request.new(env)
|
|
10
10
|
status, headers, response = @app.call(env)
|
|
11
|
+
|
|
12
|
+
config = SecureHeaders.config_for(req)
|
|
13
|
+
flag_cookies!(headers, override_secure(env, config.cookies)) if config.cookies
|
|
11
14
|
headers.merge!(SecureHeaders.header_hash_for(req))
|
|
12
15
|
[status, headers, response]
|
|
13
16
|
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
21
|
+
def flag_cookies!(headers, config)
|
|
22
|
+
if cookies = headers['Set-Cookie']
|
|
23
|
+
# Support Rails 2.3 / Rack 1.1 arrays as headers
|
|
24
|
+
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
25
|
+
|
|
26
|
+
headers['Set-Cookie'] = cookies.map do |cookie|
|
|
27
|
+
SecureHeaders::Cookie.new(cookie, config).to_s
|
|
28
|
+
end.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# disable Secure cookies for non-https requests
|
|
33
|
+
def override_secure(env, config = {})
|
|
34
|
+
if scheme(env) != 'https'
|
|
35
|
+
config.merge!(secure: false)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
|
|
42
|
+
def scheme(env)
|
|
43
|
+
if env['HTTPS'] == 'on' || env['HTTP_X_SSL_REQUEST'] == 'on'
|
|
44
|
+
'https'
|
|
45
|
+
elsif env['HTTP_X_FORWARDED_PROTO']
|
|
46
|
+
env['HTTP_X_FORWARDED_PROTO'].split(',')[0]
|
|
47
|
+
else
|
|
48
|
+
env['rack.url_scheme']
|
|
49
|
+
end
|
|
50
|
+
end
|
|
14
51
|
end
|
|
15
52
|
end
|
|
@@ -10,7 +10,11 @@ if defined?(Rails::Railtie)
|
|
|
10
10
|
'Public-Key-Pins', 'Public-Key-Pins-Report-Only']
|
|
11
11
|
|
|
12
12
|
initializer "secure_headers.middleware" do
|
|
13
|
-
Rails.application.config.middleware.
|
|
13
|
+
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
rake_tasks do
|
|
17
|
+
load File.expand_path(File.join('..', '..', 'lib', 'tasks', 'tasks.rake'), File.dirname(__FILE__))
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
initializer "secure_headers.action_controller" do
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module SecureHeaders
|
|
2
|
+
class CookiesConfig
|
|
3
|
+
|
|
4
|
+
attr_reader :config
|
|
5
|
+
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def validate!
|
|
11
|
+
return if config.nil? || config == SecureHeaders::OPT_OUT
|
|
12
|
+
|
|
13
|
+
validate_config!
|
|
14
|
+
validate_secure_config! if config[:secure]
|
|
15
|
+
validate_httponly_config! if config[:httponly]
|
|
16
|
+
validate_samesite_config! if config[:samesite]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_config!
|
|
22
|
+
raise CookiesConfigError.new("config must be a hash.") unless is_hash?(config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_secure_config!
|
|
26
|
+
validate_hash_or_boolean!(:secure)
|
|
27
|
+
validate_exclusive_use_of_hash_constraints!(config[:secure], :secure)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_httponly_config!
|
|
31
|
+
validate_hash_or_boolean!(:httponly)
|
|
32
|
+
validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_samesite_config!
|
|
36
|
+
raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite])
|
|
37
|
+
|
|
38
|
+
validate_samesite_boolean_config!
|
|
39
|
+
validate_samesite_hash_config!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# when configuring with booleans, only one enforcement is permitted
|
|
43
|
+
def validate_samesite_boolean_config!
|
|
44
|
+
if config[:samesite].key?(:lax) && config[:samesite][:lax].is_a?(TrueClass) && config[:samesite].key?(:strict)
|
|
45
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
|
|
46
|
+
elsif config[:samesite].key?(:strict) && config[:samesite][:strict].is_a?(TrueClass) && config[:samesite].key?(:lax)
|
|
47
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_samesite_hash_config!
|
|
52
|
+
# validate Hash-based samesite configuration
|
|
53
|
+
if is_hash?(config[:samesite][:lax])
|
|
54
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], 'samesite lax')
|
|
55
|
+
|
|
56
|
+
if is_hash?(config[:samesite][:strict])
|
|
57
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], 'samesite strict')
|
|
58
|
+
validate_exclusive_use_of_samesite_enforcement!(:only)
|
|
59
|
+
validate_exclusive_use_of_samesite_enforcement!(:except)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_hash_or_boolean!(attribute)
|
|
65
|
+
if !(is_hash?(config[attribute]) || is_boolean?(config[attribute]))
|
|
66
|
+
raise CookiesConfigError.new("#{attribute} cookie config must be a hash or boolean")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# validate exclusive use of only or except but not both at the same time
|
|
71
|
+
def validate_exclusive_use_of_hash_constraints!(conf, attribute)
|
|
72
|
+
return unless is_hash?(conf)
|
|
73
|
+
|
|
74
|
+
if conf.key?(:only) && conf.key?(:except)
|
|
75
|
+
raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# validate exclusivity of only and except members within strict and lax
|
|
80
|
+
def validate_exclusive_use_of_samesite_enforcement!(attribute)
|
|
81
|
+
if (intersection = (config[:samesite][:lax].fetch(attribute, []) & config[:samesite][:strict].fetch(attribute, []))).any?
|
|
82
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, cookie(s) #{intersection.join(', ')} cannot be enforced as lax and strict")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def is_hash?(obj)
|
|
87
|
+
obj && obj.is_a?(Hash)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def is_boolean?(obj)
|
|
91
|
+
obj && (obj.is_a?(TrueClass) || obj.is_a?(FalseClass))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module SecureHeaders
|
|
2
2
|
module ViewHelpers
|
|
3
|
+
include SecureHeaders::HashHelper
|
|
4
|
+
SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
|
|
5
|
+
|
|
6
|
+
class UnexpectedHashedScriptException < StandardError; end
|
|
7
|
+
|
|
3
8
|
# Public: create a style tag using the content security policy nonce.
|
|
4
9
|
# Instructs secure_headers to append a nonce to style/script-src directives.
|
|
5
10
|
#
|
|
@@ -29,8 +34,67 @@ module SecureHeaders
|
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
##
|
|
38
|
+
# Checks to see if the hashed code is expected and adds the hash source
|
|
39
|
+
# value to the current CSP.
|
|
40
|
+
#
|
|
41
|
+
# By default, in development/test/etc. an exception will be raised.
|
|
42
|
+
def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block)
|
|
43
|
+
hashed_tag(
|
|
44
|
+
:script,
|
|
45
|
+
:script_src,
|
|
46
|
+
Configuration.instance_variable_get(:@script_hashes),
|
|
47
|
+
raise_error_on_unrecognized_hash,
|
|
48
|
+
block
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block)
|
|
53
|
+
hashed_tag(
|
|
54
|
+
:style,
|
|
55
|
+
:style_src,
|
|
56
|
+
Configuration.instance_variable_get(:@style_hashes),
|
|
57
|
+
raise_error_on_unrecognized_hash,
|
|
58
|
+
block
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
32
62
|
private
|
|
33
63
|
|
|
64
|
+
def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block)
|
|
65
|
+
if raise_error_on_unrecognized_hash.nil?
|
|
66
|
+
raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
content = capture(&block)
|
|
70
|
+
file_path = File.join('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')
|
|
71
|
+
|
|
72
|
+
if raise_error_on_unrecognized_hash
|
|
73
|
+
hash_value = hash_source(content)
|
|
74
|
+
message = unexpected_hash_error_message(file_path, content, hash_value)
|
|
75
|
+
|
|
76
|
+
if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value)
|
|
77
|
+
raise UnexpectedHashedScriptException.new(message)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path])
|
|
82
|
+
|
|
83
|
+
content_tag type, content
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def unexpected_hash_error_message(file_path, content, hash_value)
|
|
87
|
+
<<-EOF
|
|
88
|
+
\n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
|
|
89
|
+
#{content}
|
|
90
|
+
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/script_hashes.yml:***
|
|
91
|
+
#{file_path}:
|
|
92
|
+
- #{hash_value}\n\n
|
|
93
|
+
NOTE: dynamic javascript is not supported using script hash integration
|
|
94
|
+
on purpose. It defeats the point of using it in the first place.
|
|
95
|
+
EOF
|
|
96
|
+
end
|
|
97
|
+
|
|
34
98
|
def nonced_tag(type, content_or_options, block)
|
|
35
99
|
options = {}
|
|
36
100
|
content = if block
|