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
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
1
3
|
module SecureHeaders
|
|
2
4
|
class Configuration
|
|
3
5
|
DEFAULT_CONFIG = :default
|
|
4
6
|
NOOP_CONFIGURATION = "secure_headers_noop_config"
|
|
5
7
|
class NotYetConfiguredError < StandardError; end
|
|
8
|
+
class IllegalPolicyModificationError < StandardError; end
|
|
6
9
|
class << self
|
|
7
10
|
# Public: Set the global default configuration.
|
|
8
11
|
#
|
|
@@ -23,12 +26,12 @@ module SecureHeaders
|
|
|
23
26
|
# if no value is supplied.
|
|
24
27
|
#
|
|
25
28
|
# Returns: the newly created config
|
|
26
|
-
def override(name, base = DEFAULT_CONFIG)
|
|
29
|
+
def override(name, base = DEFAULT_CONFIG, &block)
|
|
27
30
|
unless get(base)
|
|
28
31
|
raise NotYetConfiguredError, "#{base} policy not yet supplied"
|
|
29
32
|
end
|
|
30
33
|
override = @configurations[base].dup
|
|
31
|
-
|
|
34
|
+
override.instance_eval &block if block_given?
|
|
32
35
|
add_configuration(name, override)
|
|
33
36
|
end
|
|
34
37
|
|
|
@@ -43,18 +46,6 @@ module SecureHeaders
|
|
|
43
46
|
@configurations[name]
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
# Public: perform a basic deep dup. The shallow copy provided by dup/clone
|
|
47
|
-
# can lead to modifying parent objects.
|
|
48
|
-
def deep_copy(config)
|
|
49
|
-
config.each_with_object({}) do |(key, value), hash|
|
|
50
|
-
hash[key] = if value.is_a?(Array)
|
|
51
|
-
value.dup
|
|
52
|
-
else
|
|
53
|
-
value
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
49
|
private
|
|
59
50
|
|
|
60
51
|
# Private: add a valid configuration to the global set of named configs.
|
|
@@ -82,20 +73,51 @@ module SecureHeaders
|
|
|
82
73
|
ALL_HEADER_CLASSES.each do |klass|
|
|
83
74
|
config.send("#{klass::CONFIG_KEY}=", OPT_OUT)
|
|
84
75
|
end
|
|
76
|
+
config.dynamic_csp = OPT_OUT
|
|
85
77
|
end
|
|
86
78
|
|
|
87
79
|
add_configuration(NOOP_CONFIGURATION, noop_config)
|
|
88
80
|
end
|
|
81
|
+
|
|
82
|
+
# Public: perform a basic deep dup. The shallow copy provided by dup/clone
|
|
83
|
+
# can lead to modifying parent objects.
|
|
84
|
+
def deep_copy(config)
|
|
85
|
+
config.each_with_object({}) do |(key, value), hash|
|
|
86
|
+
hash[key] = if value.is_a?(Array)
|
|
87
|
+
value.dup
|
|
88
|
+
else
|
|
89
|
+
value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Private: convenience method purely DRY things up. The value may not be a
|
|
95
|
+
# hash (e.g. OPT_OUT, nil)
|
|
96
|
+
def deep_copy_if_hash(value)
|
|
97
|
+
if value.is_a?(Hash)
|
|
98
|
+
deep_copy(value)
|
|
99
|
+
else
|
|
100
|
+
value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
89
103
|
end
|
|
90
104
|
|
|
91
|
-
|
|
92
|
-
:x_xss_protection, :
|
|
93
|
-
:hpkp
|
|
94
|
-
|
|
105
|
+
attr_writer :hsts, :x_frame_options, :x_content_type_options,
|
|
106
|
+
:x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
|
|
107
|
+
:hpkp, :dynamic_csp, :cookies
|
|
108
|
+
|
|
109
|
+
attr_reader :cached_headers, :csp, :dynamic_csp, :cookies
|
|
110
|
+
|
|
111
|
+
HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
|
|
112
|
+
if File.exists?(HASH_CONFIG_FILE)
|
|
113
|
+
config = YAML.safe_load(File.open(HASH_CONFIG_FILE))
|
|
114
|
+
@script_hashes = config["scripts"]
|
|
115
|
+
@style_hashes = config["styles"]
|
|
116
|
+
end
|
|
95
117
|
|
|
96
118
|
def initialize(&block)
|
|
97
119
|
self.hpkp = OPT_OUT
|
|
98
|
-
self.csp = self.class.deep_copy
|
|
120
|
+
self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG)
|
|
99
121
|
instance_eval &block if block_given?
|
|
100
122
|
end
|
|
101
123
|
|
|
@@ -104,33 +126,45 @@ module SecureHeaders
|
|
|
104
126
|
# Returns a deep-dup'd copy of this configuration.
|
|
105
127
|
def dup
|
|
106
128
|
copy = self.class.new
|
|
107
|
-
copy.
|
|
108
|
-
copy.
|
|
109
|
-
copy.
|
|
110
|
-
copy.
|
|
111
|
-
copy.
|
|
112
|
-
copy.
|
|
113
|
-
copy.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
copy.cookies = @cookies
|
|
130
|
+
copy.csp = self.class.send(:deep_copy_if_hash, @csp)
|
|
131
|
+
copy.dynamic_csp = self.class.send(:deep_copy_if_hash, @dynamic_csp)
|
|
132
|
+
copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
|
|
133
|
+
copy.x_content_type_options = @x_content_type_options
|
|
134
|
+
copy.hsts = @hsts
|
|
135
|
+
copy.x_frame_options = @x_frame_options
|
|
136
|
+
copy.x_xss_protection = @x_xss_protection
|
|
137
|
+
copy.x_download_options = @x_download_options
|
|
138
|
+
copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies
|
|
139
|
+
copy.hpkp = @hpkp
|
|
140
|
+
copy
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def opt_out(header)
|
|
144
|
+
send("#{header}=", OPT_OUT)
|
|
145
|
+
if header == CSP::CONFIG_KEY
|
|
146
|
+
dynamic_csp = OPT_OUT
|
|
117
147
|
end
|
|
148
|
+
self.cached_headers.delete(header)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def update_x_frame_options(value)
|
|
152
|
+
@x_frame_options = value
|
|
153
|
+
self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
|
|
154
|
+
end
|
|
118
155
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
156
|
+
# Public: generated cached headers for a specific user agent.
|
|
157
|
+
def rebuild_csp_header_cache!(user_agent)
|
|
158
|
+
self.cached_headers[CSP::CONFIG_KEY] = {}
|
|
159
|
+
unless current_csp == OPT_OUT
|
|
160
|
+
user_agent = UserAgent.parse(user_agent)
|
|
161
|
+
variation = CSP.ua_to_variation(user_agent)
|
|
162
|
+
self.cached_headers[CSP::CONFIG_KEY][variation] = CSP.make_header(current_csp, user_agent)
|
|
123
163
|
end
|
|
124
|
-
copy
|
|
125
164
|
end
|
|
126
165
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# Returns the value if available, and returns a dup of any hash values.
|
|
130
|
-
def fetch(key)
|
|
131
|
-
config = send(key)
|
|
132
|
-
config = self.class.deep_copy(config) if config.is_a?(Hash)
|
|
133
|
-
config
|
|
166
|
+
def current_csp
|
|
167
|
+
@dynamic_csp || @csp
|
|
134
168
|
end
|
|
135
169
|
|
|
136
170
|
# Public: validates all configurations values.
|
|
@@ -139,16 +173,38 @@ module SecureHeaders
|
|
|
139
173
|
#
|
|
140
174
|
# Returns nothing
|
|
141
175
|
def validate_config!
|
|
142
|
-
StrictTransportSecurity.validate_config!(hsts)
|
|
143
|
-
ContentSecurityPolicy.validate_config!(csp)
|
|
144
|
-
XFrameOptions.validate_config!(x_frame_options)
|
|
145
|
-
XContentTypeOptions.validate_config!(x_content_type_options)
|
|
146
|
-
XXssProtection.validate_config!(x_xss_protection)
|
|
147
|
-
XDownloadOptions.validate_config!(x_download_options)
|
|
148
|
-
XPermittedCrossDomainPolicies.validate_config!(x_permitted_cross_domain_policies)
|
|
149
|
-
PublicKeyPins.validate_config!(hpkp)
|
|
176
|
+
StrictTransportSecurity.validate_config!(@hsts)
|
|
177
|
+
ContentSecurityPolicy.validate_config!(@csp)
|
|
178
|
+
XFrameOptions.validate_config!(@x_frame_options)
|
|
179
|
+
XContentTypeOptions.validate_config!(@x_content_type_options)
|
|
180
|
+
XXssProtection.validate_config!(@x_xss_protection)
|
|
181
|
+
XDownloadOptions.validate_config!(@x_download_options)
|
|
182
|
+
XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
|
|
183
|
+
PublicKeyPins.validate_config!(@hpkp)
|
|
184
|
+
Cookie.validate_config!(@cookies)
|
|
150
185
|
end
|
|
151
186
|
|
|
187
|
+
def secure_cookies=(secure_cookies)
|
|
188
|
+
Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#secure_cookies=` is deprecated. Please use `#cookies=` to configure secure cookies instead."
|
|
189
|
+
@cookies = (@cookies || {}).merge(secure: secure_cookies)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
protected
|
|
193
|
+
|
|
194
|
+
def csp=(new_csp)
|
|
195
|
+
if self.dynamic_csp
|
|
196
|
+
raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= instead."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@csp = new_csp
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def cached_headers=(headers)
|
|
203
|
+
@cached_headers = headers
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
152
208
|
# Public: Precompute the header names and values for this configuraiton.
|
|
153
209
|
# Ensures that headers generated at configure time, not on demand.
|
|
154
210
|
#
|
|
@@ -156,7 +212,7 @@ module SecureHeaders
|
|
|
156
212
|
def cache_headers!
|
|
157
213
|
# generate defaults for the "easy" headers
|
|
158
214
|
headers = (ALL_HEADERS_BESIDES_CSP).each_with_object({}) do |klass, hash|
|
|
159
|
-
config =
|
|
215
|
+
config = instance_variable_get("@#{klass::CONFIG_KEY}")
|
|
160
216
|
unless config == OPT_OUT
|
|
161
217
|
hash[klass::CONFIG_KEY] = klass.make_header(config).freeze
|
|
162
218
|
end
|
|
@@ -165,7 +221,7 @@ module SecureHeaders
|
|
|
165
221
|
generate_csp_headers(headers)
|
|
166
222
|
|
|
167
223
|
headers.freeze
|
|
168
|
-
|
|
224
|
+
self.cached_headers = headers
|
|
169
225
|
end
|
|
170
226
|
|
|
171
227
|
# Private: adds CSP headers for each variation of CSP support.
|
|
@@ -175,11 +231,10 @@ module SecureHeaders
|
|
|
175
231
|
#
|
|
176
232
|
# Returns nothing
|
|
177
233
|
def generate_csp_headers(headers)
|
|
178
|
-
unless csp == OPT_OUT
|
|
234
|
+
unless @csp == OPT_OUT
|
|
179
235
|
headers[CSP::CONFIG_KEY] = {}
|
|
180
|
-
|
|
236
|
+
csp_config = self.current_csp
|
|
181
237
|
CSP::VARIATIONS.each do |name, _|
|
|
182
|
-
csp_config = fetch(CSP::CONFIG_KEY)
|
|
183
238
|
csp = CSP.make_header(csp_config, UserAgent.parse(name))
|
|
184
239
|
headers[CSP::CONFIG_KEY][name] = csp.freeze
|
|
185
240
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
|
|
3
|
+
module SecureHeaders
|
|
4
|
+
module HashHelper
|
|
5
|
+
def hash_source(inline_script, digest = :SHA256)
|
|
6
|
+
base64_hashed_content = Base64.encode64(Digest.const_get(digest).digest(inline_script)).chomp
|
|
7
|
+
"'#{digest.to_s.downcase}-#{base64_hashed_content}'"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -1,296 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
require 'base64'
|
|
3
|
-
require 'securerandom'
|
|
4
|
-
require 'json'
|
|
1
|
+
require_relative 'policy_management'
|
|
5
2
|
|
|
6
3
|
module SecureHeaders
|
|
7
4
|
class ContentSecurityPolicyConfigError < StandardError; end
|
|
8
5
|
class ContentSecurityPolicy
|
|
9
|
-
|
|
10
|
-
DEFAULT_VALUE = "default-src https:".freeze
|
|
11
|
-
DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
|
|
12
|
-
HEADER_NAME = "Content-Security-Policy".freeze
|
|
13
|
-
REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze
|
|
14
|
-
HEADER_NAMES = [HEADER_NAME, REPORT_ONLY]
|
|
15
|
-
DATA_PROTOCOL = "data:".freeze
|
|
16
|
-
BLOB_PROTOCOL = "blob:".freeze
|
|
17
|
-
SELF = "'self'".freeze
|
|
18
|
-
NONE = "'none'".freeze
|
|
19
|
-
STAR = "*".freeze
|
|
20
|
-
UNSAFE_INLINE = "'unsafe-inline'".freeze
|
|
21
|
-
UNSAFE_EVAL = "'unsafe-eval'".freeze
|
|
22
|
-
|
|
23
|
-
# leftover deprecated values that will be in common use upon upgrading.
|
|
24
|
-
DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
|
|
25
|
-
|
|
26
|
-
DEFAULT_SRC = :default_src
|
|
27
|
-
CONNECT_SRC = :connect_src
|
|
28
|
-
FONT_SRC = :font_src
|
|
29
|
-
FRAME_SRC = :frame_src
|
|
30
|
-
IMG_SRC = :img_src
|
|
31
|
-
MEDIA_SRC = :media_src
|
|
32
|
-
OBJECT_SRC = :object_src
|
|
33
|
-
SANDBOX = :sandbox
|
|
34
|
-
SCRIPT_SRC = :script_src
|
|
35
|
-
STYLE_SRC = :style_src
|
|
36
|
-
REPORT_URI = :report_uri
|
|
37
|
-
|
|
38
|
-
DIRECTIVES_1_0 = [
|
|
39
|
-
DEFAULT_SRC,
|
|
40
|
-
CONNECT_SRC,
|
|
41
|
-
FONT_SRC,
|
|
42
|
-
FRAME_SRC,
|
|
43
|
-
IMG_SRC,
|
|
44
|
-
MEDIA_SRC,
|
|
45
|
-
OBJECT_SRC,
|
|
46
|
-
SANDBOX,
|
|
47
|
-
SCRIPT_SRC,
|
|
48
|
-
STYLE_SRC,
|
|
49
|
-
REPORT_URI
|
|
50
|
-
].freeze
|
|
51
|
-
|
|
52
|
-
BASE_URI = :base_uri
|
|
53
|
-
CHILD_SRC = :child_src
|
|
54
|
-
FORM_ACTION = :form_action
|
|
55
|
-
FRAME_ANCESTORS = :frame_ancestors
|
|
56
|
-
PLUGIN_TYPES = :plugin_types
|
|
57
|
-
|
|
58
|
-
# These are directives that do not inherit the default-src value. This is
|
|
59
|
-
# useful when calling #combine_policies.
|
|
60
|
-
NON_DEFAULT_SOURCES = [
|
|
61
|
-
BASE_URI,
|
|
62
|
-
FORM_ACTION,
|
|
63
|
-
FRAME_ANCESTORS,
|
|
64
|
-
PLUGIN_TYPES,
|
|
65
|
-
REPORT_URI
|
|
66
|
-
]
|
|
67
|
-
|
|
68
|
-
DIRECTIVES_2_0 = [
|
|
69
|
-
DIRECTIVES_1_0,
|
|
70
|
-
BASE_URI,
|
|
71
|
-
CHILD_SRC,
|
|
72
|
-
FORM_ACTION,
|
|
73
|
-
FRAME_ANCESTORS,
|
|
74
|
-
PLUGIN_TYPES
|
|
75
|
-
].flatten.freeze
|
|
76
|
-
|
|
77
|
-
# All the directives currently under consideration for CSP level 3.
|
|
78
|
-
# https://w3c.github.io/webappsec/specs/CSP2/
|
|
79
|
-
MANIFEST_SRC = :manifest_src
|
|
80
|
-
REFLECTED_XSS = :reflected_xss
|
|
81
|
-
DIRECTIVES_3_0 = [
|
|
82
|
-
DIRECTIVES_2_0,
|
|
83
|
-
MANIFEST_SRC,
|
|
84
|
-
REFLECTED_XSS
|
|
85
|
-
].flatten.freeze
|
|
86
|
-
|
|
87
|
-
# All the directives that are not currently in a formal spec, but have
|
|
88
|
-
# been implemented somewhere.
|
|
89
|
-
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
90
|
-
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
91
|
-
DIRECTIVES_DRAFT = [
|
|
92
|
-
BLOCK_ALL_MIXED_CONTENT,
|
|
93
|
-
UPGRADE_INSECURE_REQUESTS
|
|
94
|
-
].freeze
|
|
95
|
-
|
|
96
|
-
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
97
|
-
|
|
98
|
-
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
99
|
-
BLOCK_ALL_MIXED_CONTENT,
|
|
100
|
-
CHILD_SRC,
|
|
101
|
-
PLUGIN_TYPES
|
|
102
|
-
].freeze
|
|
103
|
-
|
|
104
|
-
FIREFOX_DIRECTIVES = (
|
|
105
|
-
DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
106
|
-
).freeze
|
|
107
|
-
|
|
108
|
-
CHROME_DIRECTIVES = (
|
|
109
|
-
DIRECTIVES_2_0 + DIRECTIVES_DRAFT
|
|
110
|
-
).freeze
|
|
111
|
-
|
|
112
|
-
ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
|
|
113
|
-
|
|
114
|
-
# Think of default-src and report-uri as the beginning and end respectively,
|
|
115
|
-
# everything else is in between.
|
|
116
|
-
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
117
|
-
|
|
118
|
-
VARIATIONS = {
|
|
119
|
-
"Chrome" => CHROME_DIRECTIVES,
|
|
120
|
-
"Opera" => CHROME_DIRECTIVES,
|
|
121
|
-
"Firefox" => FIREFOX_DIRECTIVES,
|
|
122
|
-
"Safari" => SAFARI_DIRECTIVES,
|
|
123
|
-
"Other" => CHROME_DIRECTIVES
|
|
124
|
-
}.freeze
|
|
125
|
-
|
|
126
|
-
OTHER = "Other".freeze
|
|
127
|
-
|
|
128
|
-
DIRECTIVE_VALUE_TYPES = {
|
|
129
|
-
BASE_URI => :source_list,
|
|
130
|
-
BLOCK_ALL_MIXED_CONTENT => :boolean,
|
|
131
|
-
CHILD_SRC => :source_list,
|
|
132
|
-
CONNECT_SRC => :source_list,
|
|
133
|
-
DEFAULT_SRC => :source_list,
|
|
134
|
-
FONT_SRC => :source_list,
|
|
135
|
-
FORM_ACTION => :source_list,
|
|
136
|
-
FRAME_ANCESTORS => :source_list,
|
|
137
|
-
FRAME_SRC => :source_list,
|
|
138
|
-
IMG_SRC => :source_list,
|
|
139
|
-
MANIFEST_SRC => :source_list,
|
|
140
|
-
MEDIA_SRC => :source_list,
|
|
141
|
-
OBJECT_SRC => :source_list,
|
|
142
|
-
PLUGIN_TYPES => :source_list,
|
|
143
|
-
REFLECTED_XSS => :string,
|
|
144
|
-
REPORT_URI => :source_list,
|
|
145
|
-
SANDBOX => :string,
|
|
146
|
-
SCRIPT_SRC => :source_list,
|
|
147
|
-
STYLE_SRC => :source_list,
|
|
148
|
-
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
149
|
-
}.freeze
|
|
150
|
-
|
|
151
|
-
CONFIG_KEY = :csp
|
|
152
|
-
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
153
|
-
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
154
|
-
|
|
155
|
-
WILDCARD_SOURCES = [
|
|
156
|
-
UNSAFE_EVAL,
|
|
157
|
-
UNSAFE_INLINE,
|
|
158
|
-
STAR,
|
|
159
|
-
DATA_PROTOCOL,
|
|
160
|
-
BLOB_PROTOCOL
|
|
161
|
-
].freeze
|
|
162
|
-
|
|
163
|
-
META_CONFIGS = [
|
|
164
|
-
:report_only,
|
|
165
|
-
:preserve_schemes
|
|
166
|
-
].freeze
|
|
167
|
-
|
|
168
|
-
class << self
|
|
169
|
-
# Public: generate a header name, value array that is user-agent-aware.
|
|
170
|
-
#
|
|
171
|
-
# Returns a default policy if no configuration is provided, or a
|
|
172
|
-
# header name and value based on the config.
|
|
173
|
-
def make_header(config, user_agent)
|
|
174
|
-
header = new(config, user_agent)
|
|
175
|
-
[header.name, header.value]
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Public: Validates each source expression.
|
|
179
|
-
#
|
|
180
|
-
# Does not validate the invididual values of the source expression (e.g.
|
|
181
|
-
# script_src => h*t*t*p: will not raise an exception)
|
|
182
|
-
def validate_config!(config)
|
|
183
|
-
return if config.nil? || config == OPT_OUT
|
|
184
|
-
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
|
|
185
|
-
config.each do |key, value|
|
|
186
|
-
if META_CONFIGS.include?(key)
|
|
187
|
-
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
|
188
|
-
else
|
|
189
|
-
validate_directive!(key, value)
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# Public: determine if merging +additions+ will cause a change to the
|
|
195
|
-
# actual value of the config.
|
|
196
|
-
#
|
|
197
|
-
# e.g. config = { script_src: %w(example.org google.com)} and
|
|
198
|
-
# additions = { script_src: %w(google.com)} then idempotent_additions? would return
|
|
199
|
-
# because google.com is already in the config.
|
|
200
|
-
def idempotent_additions?(config, additions)
|
|
201
|
-
return false if config == OPT_OUT
|
|
202
|
-
config.to_s == combine_policies(config, additions).to_s
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Public: combine the values from two different configs.
|
|
206
|
-
#
|
|
207
|
-
# original - the main config
|
|
208
|
-
# additions - values to be merged in
|
|
209
|
-
#
|
|
210
|
-
# raises an error if the original config is OPT_OUT
|
|
211
|
-
#
|
|
212
|
-
# 1. for non-source-list values (report_only, block_all_mixed_content, upgrade_insecure_requests),
|
|
213
|
-
# additions will overwrite the original value.
|
|
214
|
-
# 2. if a value in additions does not exist in the original config, the
|
|
215
|
-
# default-src value is included to match original behavior.
|
|
216
|
-
# 3. if a value in additions does exist in the original config, the two
|
|
217
|
-
# values are joined.
|
|
218
|
-
def combine_policies(original, additions)
|
|
219
|
-
if original == OPT_OUT
|
|
220
|
-
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
original = original.dup if original.frozen?
|
|
224
|
-
|
|
225
|
-
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
226
|
-
additions.keys.each do |directive|
|
|
227
|
-
unless original[directive] || !source_list?(directive) || NON_DEFAULT_SOURCES.include?(directive)
|
|
228
|
-
original[directive] = original[:default_src]
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# merge the two hashes. combine (instead of overwrite) the array values
|
|
233
|
-
# when each hash contains a value for a given key.
|
|
234
|
-
original.merge(additions) do |directive, lhs, rhs|
|
|
235
|
-
if source_list?(directive)
|
|
236
|
-
(lhs.to_a + rhs.to_a).compact.uniq
|
|
237
|
-
else
|
|
238
|
-
rhs
|
|
239
|
-
end
|
|
240
|
-
end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
private
|
|
244
|
-
|
|
245
|
-
def source_list?(directive)
|
|
246
|
-
DIRECTIVE_VALUE_TYPES[directive] == :source_list
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# Private: Validates that the configuration has a valid type, or that it is a valid
|
|
250
|
-
# source expression.
|
|
251
|
-
def validate_directive!(key, value)
|
|
252
|
-
case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[key]
|
|
253
|
-
when :boolean
|
|
254
|
-
unless boolean?(value)
|
|
255
|
-
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value")
|
|
256
|
-
end
|
|
257
|
-
when :string
|
|
258
|
-
unless value.is_a?(String)
|
|
259
|
-
raise ContentSecurityPolicyConfigError.new("#{key} Must be a string. Found #{config.class}: #{config} value")
|
|
260
|
-
end
|
|
261
|
-
else
|
|
262
|
-
validate_source_expression!(key, value)
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
# Private: validates that a source expression:
|
|
267
|
-
# 1. has a valid name
|
|
268
|
-
# 2. is an array of strings
|
|
269
|
-
# 3. does not contain any depreated, now invalid values (inline, eval, self, none)
|
|
270
|
-
#
|
|
271
|
-
# Does not validate the invididual values of the source expression (e.g.
|
|
272
|
-
# script_src => h*t*t*p: will not raise an exception)
|
|
273
|
-
def validate_source_expression!(key, value)
|
|
274
|
-
unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(key)
|
|
275
|
-
raise ContentSecurityPolicyConfigError.new("Unknown directive #{key}")
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
unless value.is_a?(Array) && value.compact.all? { |v| v.is_a?(String) }
|
|
279
|
-
raise ContentSecurityPolicyConfigError.new("#{key} must be an array of strings")
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
value.each do |source_expression|
|
|
283
|
-
if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(source_expression)
|
|
284
|
-
raise ContentSecurityPolicyConfigError.new("#{key} contains an invalid keyword source (#{source_expression}). This value must be single quoted.")
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def boolean?(value)
|
|
290
|
-
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
6
|
+
include PolicyManagement
|
|
294
7
|
|
|
295
8
|
def initialize(config = nil, user_agent = OTHER)
|
|
296
9
|
config = Configuration.deep_copy(DEFAULT_CONFIG) unless config
|
|
@@ -350,36 +63,45 @@ module SecureHeaders
|
|
|
350
63
|
end
|
|
351
64
|
|
|
352
65
|
# Private: builds a string that represents one directive in a minified form.
|
|
353
|
-
# If a directive contains *, all other values are omitted.
|
|
354
|
-
# If a directive contains 'none' but has other values, 'none' is ommitted.
|
|
355
|
-
# Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
|
|
356
66
|
#
|
|
357
67
|
# directive_name - a symbol representing the various ALL_DIRECTIVES
|
|
358
68
|
#
|
|
359
69
|
# Returns a string representing a directive.
|
|
360
|
-
def build_directive(
|
|
361
|
-
return if @config[
|
|
70
|
+
def build_directive(directive)
|
|
71
|
+
return if @config[directive].nil?
|
|
362
72
|
|
|
363
|
-
source_list = @config[
|
|
73
|
+
source_list = @config[directive].compact
|
|
364
74
|
return if source_list.empty?
|
|
365
75
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
else
|
|
370
|
-
populate_nonces(directive_name, source_list)
|
|
76
|
+
normalized_source_list = minify_source_list(directive, source_list)
|
|
77
|
+
[symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
|
|
78
|
+
end
|
|
371
79
|
|
|
372
|
-
|
|
373
|
-
|
|
80
|
+
# If a directive contains *, all other values are omitted.
|
|
81
|
+
# If a directive contains 'none' but has other values, 'none' is ommitted.
|
|
82
|
+
# Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
|
|
83
|
+
def minify_source_list(directive, source_list)
|
|
84
|
+
if source_list.include?(STAR)
|
|
85
|
+
keep_wildcard_sources(source_list)
|
|
86
|
+
else
|
|
87
|
+
populate_nonces!(directive, source_list)
|
|
88
|
+
reject_all_values_if_none!(source_list)
|
|
374
89
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
source_list = strip_source_schemes(source_list)
|
|
90
|
+
unless directive == REPORT_URI || @preserve_schemes
|
|
91
|
+
strip_source_schemes!(source_list)
|
|
378
92
|
end
|
|
379
93
|
dedup_source_list(source_list).join(" ")
|
|
380
94
|
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Discard trailing entries (excluding unsafe-*) since * accomplishes the same.
|
|
98
|
+
def keep_wildcard_sources(source_list)
|
|
99
|
+
source_list.select { |value| WILDCARD_SOURCES.include?(value) }
|
|
100
|
+
end
|
|
381
101
|
|
|
382
|
-
|
|
102
|
+
# Discard any 'none' values if more directives are supplied since none may override values.
|
|
103
|
+
def reject_all_values_if_none!(source_list)
|
|
104
|
+
source_list.reject! { |value| value == NONE } if source_list.length > 1
|
|
383
105
|
end
|
|
384
106
|
|
|
385
107
|
# Removes duplicates and sources that already match an existing wild card.
|
|
@@ -401,7 +123,7 @@ module SecureHeaders
|
|
|
401
123
|
|
|
402
124
|
# Private: append a nonce to the script/style directories if script_nonce
|
|
403
125
|
# or style_nonce are provided.
|
|
404
|
-
def populate_nonces(directive, source_list)
|
|
126
|
+
def populate_nonces!(directive, source_list)
|
|
405
127
|
case directive
|
|
406
128
|
when SCRIPT_SRC
|
|
407
129
|
append_nonce(source_list, @script_nonce)
|
|
@@ -434,8 +156,8 @@ module SecureHeaders
|
|
|
434
156
|
end
|
|
435
157
|
|
|
436
158
|
# Private: Remove scheme from source expressions.
|
|
437
|
-
def strip_source_schemes(source_list)
|
|
438
|
-
source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
|
|
159
|
+
def strip_source_schemes!(source_list)
|
|
160
|
+
source_list.map! { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
|
|
439
161
|
end
|
|
440
162
|
|
|
441
163
|
# Private: determine which directives are supported for the given user agent.
|