secure_headers 3.2.0 → 3.7.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 +1 -0
- data/.ruby-version +1 -1
- data/.travis.yml +2 -0
- data/CHANGELOG.md +140 -1
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +3 -1
- data/LICENSE +4 -199
- data/README.md +74 -334
- data/docs/HPKP.md +17 -0
- data/docs/cookies.md +50 -0
- data/docs/hashes.md +64 -0
- data/docs/named_overrides_and_appends.md +107 -0
- data/docs/per_action_configuration.md +105 -0
- data/docs/sinatra.md +25 -0
- data/lib/secure_headers/configuration.rb +120 -39
- data/lib/secure_headers/headers/clear_site_data.rb +54 -0
- data/lib/secure_headers/headers/content_security_policy.rb +89 -32
- data/lib/secure_headers/headers/content_security_policy_config.rb +161 -0
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
- data/lib/secure_headers/headers/policy_management.rb +84 -49
- data/lib/secure_headers/headers/public_key_pins.rb +1 -1
- data/lib/secure_headers/headers/referrer_policy.rb +36 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +1 -1
- data/lib/secure_headers/headers/x_content_type_options.rb +1 -1
- data/lib/secure_headers/headers/x_download_options.rb +1 -1
- data/lib/secure_headers/headers/x_frame_options.rb +1 -1
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +1 -1
- data/lib/secure_headers/headers/x_xss_protection.rb +1 -1
- data/lib/secure_headers/middleware.rb +6 -0
- data/lib/secure_headers/railtie.rb +1 -1
- data/lib/secure_headers/view_helper.rb +13 -7
- data/lib/secure_headers.rb +138 -55
- data/lib/tasks/tasks.rake +5 -4
- data/secure_headers.gemspec +2 -2
- data/spec/lib/secure_headers/configuration_spec.rb +5 -5
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +86 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +69 -12
- data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +28 -36
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +72 -0
- data/spec/lib/secure_headers/middleware_spec.rb +31 -1
- data/spec/lib/secure_headers/view_helpers_spec.rb +19 -7
- data/spec/lib/secure_headers_spec.rb +344 -44
- data/spec/spec_helper.rb +8 -3
- data/upgrading-to-3-0.md +13 -10
- metadata +24 -4
|
@@ -1,33 +1,45 @@
|
|
|
1
1
|
require_relative 'policy_management'
|
|
2
|
+
require_relative 'content_security_policy_config'
|
|
3
|
+
require 'useragent'
|
|
2
4
|
|
|
3
5
|
module SecureHeaders
|
|
4
|
-
class ContentSecurityPolicyConfigError < StandardError; end
|
|
5
6
|
class ContentSecurityPolicy
|
|
6
7
|
include PolicyManagement
|
|
7
8
|
|
|
9
|
+
# constants to be used for version-specific UA sniffing
|
|
10
|
+
VERSION_46 = ::UserAgent::Version.new("46")
|
|
11
|
+
VERSION_10 = ::UserAgent::Version.new("10")
|
|
12
|
+
FALLBACK_VERSION = ::UserAgent::Version.new("0")
|
|
13
|
+
|
|
8
14
|
def initialize(config = nil, user_agent = OTHER)
|
|
9
|
-
config =
|
|
10
|
-
|
|
15
|
+
@config = if config.is_a?(Hash)
|
|
16
|
+
if config[:report_only]
|
|
17
|
+
ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
|
|
18
|
+
else
|
|
19
|
+
ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
|
|
20
|
+
end
|
|
21
|
+
elsif config.nil?
|
|
22
|
+
ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
|
|
23
|
+
else
|
|
24
|
+
config
|
|
25
|
+
end
|
|
26
|
+
|
|
11
27
|
@parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
|
|
12
28
|
user_agent
|
|
13
29
|
else
|
|
14
30
|
UserAgent.parse(user_agent)
|
|
15
31
|
end
|
|
16
|
-
@
|
|
17
|
-
@preserve_schemes = @config
|
|
18
|
-
@script_nonce = @config
|
|
19
|
-
@style_nonce = @config
|
|
32
|
+
@frame_src = normalize_child_frame_src
|
|
33
|
+
@preserve_schemes = @config.preserve_schemes
|
|
34
|
+
@script_nonce = @config.script_nonce
|
|
35
|
+
@style_nonce = @config.style_nonce
|
|
20
36
|
end
|
|
21
37
|
|
|
22
38
|
##
|
|
23
39
|
# Returns the name to use for the header. Either "Content-Security-Policy" or
|
|
24
40
|
# "Content-Security-Policy-Report-Only"
|
|
25
41
|
def name
|
|
26
|
-
|
|
27
|
-
REPORT_ONLY
|
|
28
|
-
else
|
|
29
|
-
HEADER_NAME
|
|
30
|
-
end
|
|
42
|
+
@config.class.const_get(:HEADER_NAME)
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
##
|
|
@@ -42,6 +54,20 @@ module SecureHeaders
|
|
|
42
54
|
|
|
43
55
|
private
|
|
44
56
|
|
|
57
|
+
# frame-src is deprecated, child-src is being implemented. They are
|
|
58
|
+
# very similar and in most cases, the same value can be used for both.
|
|
59
|
+
def normalize_child_frame_src
|
|
60
|
+
if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
|
|
61
|
+
Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers.")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if supported_directives.include?(:child_src)
|
|
65
|
+
@config.child_src || @config.frame_src
|
|
66
|
+
else
|
|
67
|
+
@config.frame_src || @config.child_src
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
45
71
|
# Private: converts the config object into a string representing a policy.
|
|
46
72
|
# Places default-src at the first directive and report-uri as the last. All
|
|
47
73
|
# others are presented in alphabetical order.
|
|
@@ -53,9 +79,9 @@ module SecureHeaders
|
|
|
53
79
|
directives.map do |directive_name|
|
|
54
80
|
case DIRECTIVE_VALUE_TYPES[directive_name]
|
|
55
81
|
when :boolean
|
|
56
|
-
symbol_to_hyphen_case(directive_name)
|
|
82
|
+
symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
|
|
57
83
|
when :string
|
|
58
|
-
[symbol_to_hyphen_case(directive_name), @config
|
|
84
|
+
[symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ")
|
|
59
85
|
else
|
|
60
86
|
build_directive(directive_name)
|
|
61
87
|
end
|
|
@@ -68,11 +94,19 @@ module SecureHeaders
|
|
|
68
94
|
#
|
|
69
95
|
# Returns a string representing a directive.
|
|
70
96
|
def build_directive(directive)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
source_list = case directive
|
|
98
|
+
when :child_src
|
|
99
|
+
if supported_directives.include?(:child_src)
|
|
100
|
+
@frame_src
|
|
101
|
+
end
|
|
102
|
+
when :frame_src
|
|
103
|
+
unless supported_directives.include?(:child_src)
|
|
104
|
+
@frame_src
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
@config.directive_value(directive)
|
|
108
|
+
end
|
|
109
|
+
return unless source_list && source_list.any?
|
|
76
110
|
normalized_source_list = minify_source_list(directive, source_list)
|
|
77
111
|
[symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
|
|
78
112
|
end
|
|
@@ -81,16 +115,17 @@ module SecureHeaders
|
|
|
81
115
|
# If a directive contains 'none' but has other values, 'none' is ommitted.
|
|
82
116
|
# Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
|
|
83
117
|
def minify_source_list(directive, source_list)
|
|
118
|
+
source_list = source_list.compact
|
|
84
119
|
if source_list.include?(STAR)
|
|
85
120
|
keep_wildcard_sources(source_list)
|
|
86
121
|
else
|
|
87
|
-
populate_nonces
|
|
88
|
-
reject_all_values_if_none
|
|
122
|
+
source_list = populate_nonces(directive, source_list)
|
|
123
|
+
source_list = reject_all_values_if_none(source_list)
|
|
89
124
|
|
|
90
125
|
unless directive == REPORT_URI || @preserve_schemes
|
|
91
|
-
strip_source_schemes
|
|
126
|
+
source_list = strip_source_schemes(source_list)
|
|
92
127
|
end
|
|
93
|
-
dedup_source_list(source_list)
|
|
128
|
+
dedup_source_list(source_list)
|
|
94
129
|
end
|
|
95
130
|
end
|
|
96
131
|
|
|
@@ -100,8 +135,12 @@ module SecureHeaders
|
|
|
100
135
|
end
|
|
101
136
|
|
|
102
137
|
# Discard any 'none' values if more directives are supplied since none may override values.
|
|
103
|
-
def reject_all_values_if_none
|
|
104
|
-
|
|
138
|
+
def reject_all_values_if_none(source_list)
|
|
139
|
+
if source_list.length > 1
|
|
140
|
+
source_list.reject { |value| value == NONE }
|
|
141
|
+
else
|
|
142
|
+
source_list
|
|
143
|
+
end
|
|
105
144
|
end
|
|
106
145
|
|
|
107
146
|
# Removes duplicates and sources that already match an existing wild card.
|
|
@@ -123,12 +162,14 @@ module SecureHeaders
|
|
|
123
162
|
|
|
124
163
|
# Private: append a nonce to the script/style directories if script_nonce
|
|
125
164
|
# or style_nonce are provided.
|
|
126
|
-
def populate_nonces
|
|
165
|
+
def populate_nonces(directive, source_list)
|
|
127
166
|
case directive
|
|
128
167
|
when SCRIPT_SRC
|
|
129
168
|
append_nonce(source_list, @script_nonce)
|
|
130
169
|
when STYLE_SRC
|
|
131
170
|
append_nonce(source_list, @style_nonce)
|
|
171
|
+
else
|
|
172
|
+
source_list
|
|
132
173
|
end
|
|
133
174
|
end
|
|
134
175
|
|
|
@@ -145,30 +186,46 @@ module SecureHeaders
|
|
|
145
186
|
source_list << UNSAFE_INLINE
|
|
146
187
|
end
|
|
147
188
|
end
|
|
189
|
+
|
|
190
|
+
source_list
|
|
148
191
|
end
|
|
149
192
|
|
|
150
193
|
# Private: return the list of directives that are supported by the user agent,
|
|
151
194
|
# starting with default-src and ending with report-uri.
|
|
152
195
|
def directives
|
|
153
|
-
[
|
|
196
|
+
[
|
|
197
|
+
DEFAULT_SRC,
|
|
154
198
|
BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
|
|
155
|
-
REPORT_URI
|
|
199
|
+
REPORT_URI
|
|
200
|
+
].flatten
|
|
156
201
|
end
|
|
157
202
|
|
|
158
203
|
# Private: Remove scheme from source expressions.
|
|
159
|
-
def strip_source_schemes
|
|
160
|
-
source_list.map
|
|
204
|
+
def strip_source_schemes(source_list)
|
|
205
|
+
source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
|
|
161
206
|
end
|
|
162
207
|
|
|
163
208
|
# Private: determine which directives are supported for the given user agent.
|
|
164
209
|
#
|
|
210
|
+
# Add UA-sniffing special casing here.
|
|
211
|
+
#
|
|
165
212
|
# Returns an array of symbols representing the directives.
|
|
166
213
|
def supported_directives
|
|
167
|
-
@supported_directives ||= VARIATIONS[@parsed_ua.browser]
|
|
214
|
+
@supported_directives ||= if VARIATIONS[@parsed_ua.browser]
|
|
215
|
+
if @parsed_ua.browser == "Firefox" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_46)
|
|
216
|
+
VARIATIONS["FirefoxTransitional"]
|
|
217
|
+
elsif @parsed_ua.browser == "Safari" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_10)
|
|
218
|
+
VARIATIONS["SafariTransitional"]
|
|
219
|
+
else
|
|
220
|
+
VARIATIONS[@parsed_ua.browser]
|
|
221
|
+
end
|
|
222
|
+
else
|
|
223
|
+
VARIATIONS[OTHER]
|
|
224
|
+
end
|
|
168
225
|
end
|
|
169
226
|
|
|
170
227
|
def nonces_supported?
|
|
171
|
-
@nonces_supported ||=
|
|
228
|
+
@nonces_supported ||= self.class.nonces_supported?(@parsed_ua)
|
|
172
229
|
end
|
|
173
230
|
|
|
174
231
|
def symbol_to_hyphen_case(sym)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module SecureHeaders
|
|
2
|
+
module DynamicConfig
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.send(:attr_writer, :modified)
|
|
5
|
+
base.send(:attr_reader, *base.attrs)
|
|
6
|
+
base.attrs.each do |attr|
|
|
7
|
+
base.send(:define_method, "#{attr}=") do |value|
|
|
8
|
+
if self.class.attrs.include?(attr)
|
|
9
|
+
write_attribute(attr, value)
|
|
10
|
+
else
|
|
11
|
+
raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(hash)
|
|
18
|
+
@base_uri = nil
|
|
19
|
+
@block_all_mixed_content = nil
|
|
20
|
+
@child_src = nil
|
|
21
|
+
@connect_src = nil
|
|
22
|
+
@default_src = nil
|
|
23
|
+
@font_src = nil
|
|
24
|
+
@form_action = nil
|
|
25
|
+
@frame_ancestors = nil
|
|
26
|
+
@frame_src = nil
|
|
27
|
+
@img_src = nil
|
|
28
|
+
@manifest_src = nil
|
|
29
|
+
@media_src = nil
|
|
30
|
+
@object_src = nil
|
|
31
|
+
@plugin_types = nil
|
|
32
|
+
@preserve_schemes = nil
|
|
33
|
+
@report_only = nil
|
|
34
|
+
@report_uri = nil
|
|
35
|
+
@sandbox = nil
|
|
36
|
+
@script_nonce = nil
|
|
37
|
+
@script_src = nil
|
|
38
|
+
@style_nonce = nil
|
|
39
|
+
@style_src = nil
|
|
40
|
+
@upgrade_insecure_requests = nil
|
|
41
|
+
|
|
42
|
+
from_hash(hash)
|
|
43
|
+
@modified = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def update_directive(directive, value)
|
|
47
|
+
self.send("#{directive}=", value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def directive_value(directive)
|
|
51
|
+
if self.class.attrs.include?(directive)
|
|
52
|
+
self.send(directive)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def modified?
|
|
57
|
+
@modified
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def merge(new_hash)
|
|
61
|
+
ContentSecurityPolicy.combine_policies(self.to_h, new_hash)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def merge!(new_hash)
|
|
65
|
+
from_hash(new_hash)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def append(new_hash)
|
|
69
|
+
from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
self.class.attrs.each_with_object({}) do |key, hash|
|
|
74
|
+
value = self.send(key)
|
|
75
|
+
hash[key] = value unless value.nil?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dup
|
|
80
|
+
self.class.new(self.to_h)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def opt_out?
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ==(o)
|
|
88
|
+
self.class == o.class && self.to_h == o.to_h
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
alias_method :[], :directive_value
|
|
92
|
+
alias_method :[]=, :update_directive
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
def from_hash(hash)
|
|
96
|
+
hash.each_pair do |k, v|
|
|
97
|
+
next if v.nil?
|
|
98
|
+
|
|
99
|
+
if self.class.attrs.include?(k)
|
|
100
|
+
write_attribute(k, v)
|
|
101
|
+
else
|
|
102
|
+
raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{v}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def write_attribute(attr, value)
|
|
108
|
+
value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
|
|
109
|
+
attr_variable = "@#{attr}"
|
|
110
|
+
prev_value = self.instance_variable_get(attr_variable)
|
|
111
|
+
self.instance_variable_set(attr_variable, value)
|
|
112
|
+
if prev_value != value
|
|
113
|
+
@modified = true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class ContentSecurityPolicyConfigError < StandardError; end
|
|
119
|
+
class ContentSecurityPolicyConfig
|
|
120
|
+
CONFIG_KEY = :csp
|
|
121
|
+
HEADER_NAME = "Content-Security-Policy".freeze
|
|
122
|
+
|
|
123
|
+
ATTRS = PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
|
|
124
|
+
def self.attrs
|
|
125
|
+
ATTRS
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
include DynamicConfig
|
|
129
|
+
|
|
130
|
+
# based on what was suggested in https://github.com/rails/rails/pull/24961/files
|
|
131
|
+
DEFAULT = {
|
|
132
|
+
default_src: %w('self' https:),
|
|
133
|
+
font_src: %w('self' https: data:),
|
|
134
|
+
img_src: %w('self' https: data:),
|
|
135
|
+
object_src: %w('none'),
|
|
136
|
+
script_src: %w(https:),
|
|
137
|
+
style_src: %w('self' https: 'unsafe-inline')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def report_only?
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def make_report_only
|
|
145
|
+
ContentSecurityPolicyReportOnlyConfig.new(self.to_h)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig
|
|
150
|
+
CONFIG_KEY = :csp_report_only
|
|
151
|
+
HEADER_NAME = "Content-Security-Policy-Report-Only".freeze
|
|
152
|
+
|
|
153
|
+
def report_only?
|
|
154
|
+
true
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def make_report_only
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class ExpectCertificateTransparencyConfigError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class ExpectCertificateTransparency
|
|
6
|
+
HEADER_NAME = "Expect-CT".freeze
|
|
7
|
+
CONFIG_KEY = :expect_certificate_transparency
|
|
8
|
+
INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
|
|
9
|
+
INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze
|
|
10
|
+
REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
|
|
11
|
+
INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Public: Generate a Expect-CT header.
|
|
15
|
+
#
|
|
16
|
+
# Returns nil if not configured, returns header name and value if
|
|
17
|
+
# configured.
|
|
18
|
+
def make_header(config)
|
|
19
|
+
return if config.nil?
|
|
20
|
+
|
|
21
|
+
header = new(config)
|
|
22
|
+
[HEADER_NAME, header.value]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_config!(config)
|
|
26
|
+
return if config.nil? || config == OPT_OUT
|
|
27
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
|
|
28
|
+
|
|
29
|
+
unless [true, false, nil].include?(config[:enforce])
|
|
30
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if !config[:max_age]
|
|
34
|
+
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
|
|
35
|
+
elsif config[:max_age].to_s !~ /\A\d+\z/
|
|
36
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(config)
|
|
42
|
+
@enforced = config.fetch(:enforce, nil)
|
|
43
|
+
@max_age = config.fetch(:max_age, nil)
|
|
44
|
+
@report_uri = config.fetch(:report_uri, nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def value
|
|
48
|
+
header_value = [
|
|
49
|
+
enforced_directive,
|
|
50
|
+
max_age_directive,
|
|
51
|
+
report_uri_directive
|
|
52
|
+
].compact.join("; ").strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enforced_directive
|
|
56
|
+
# Unfortunately `if @enforced` isn't enough here in case someone
|
|
57
|
+
# passes in a random string so let's be specific with it to prevent
|
|
58
|
+
# accidental enforcement.
|
|
59
|
+
"enforce" if @enforced == true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def max_age_directive
|
|
63
|
+
"max-age=#{@max_age}" if @max_age
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def report_uri_directive
|
|
67
|
+
"report-uri=\"#{@report_uri}\"" if @report_uri
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -7,9 +7,6 @@ module SecureHeaders
|
|
|
7
7
|
MODERN_BROWSERS = %w(Chrome Opera Firefox)
|
|
8
8
|
DEFAULT_VALUE = "default-src https:".freeze
|
|
9
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
10
|
DATA_PROTOCOL = "data:".freeze
|
|
14
11
|
BLOB_PROTOCOL = "blob:".freeze
|
|
15
12
|
SELF = "'self'".freeze
|
|
@@ -17,6 +14,7 @@ module SecureHeaders
|
|
|
17
14
|
STAR = "*".freeze
|
|
18
15
|
UNSAFE_INLINE = "'unsafe-inline'".freeze
|
|
19
16
|
UNSAFE_EVAL = "'unsafe-eval'".freeze
|
|
17
|
+
STRICT_DYNAMIC = "'strict-dynamic'".freeze
|
|
20
18
|
|
|
21
19
|
# leftover deprecated values that will be in common use upon upgrading.
|
|
22
20
|
DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
|
|
@@ -53,16 +51,6 @@ module SecureHeaders
|
|
|
53
51
|
FRAME_ANCESTORS = :frame_ancestors
|
|
54
52
|
PLUGIN_TYPES = :plugin_types
|
|
55
53
|
|
|
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
54
|
DIRECTIVES_2_0 = [
|
|
67
55
|
DIRECTIVES_1_0,
|
|
68
56
|
BASE_URI,
|
|
@@ -74,24 +62,19 @@ module SecureHeaders
|
|
|
74
62
|
|
|
75
63
|
# All the directives currently under consideration for CSP level 3.
|
|
76
64
|
# https://w3c.github.io/webappsec/specs/CSP2/
|
|
65
|
+
BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
|
|
77
66
|
MANIFEST_SRC = :manifest_src
|
|
78
|
-
|
|
67
|
+
UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
|
|
79
68
|
DIRECTIVES_3_0 = [
|
|
80
69
|
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
70
|
BLOCK_ALL_MIXED_CONTENT,
|
|
71
|
+
MANIFEST_SRC,
|
|
91
72
|
UPGRADE_INSECURE_REQUESTS
|
|
92
|
-
].freeze
|
|
73
|
+
].flatten.freeze
|
|
93
74
|
|
|
75
|
+
EDGE_DIRECTIVES = DIRECTIVES_1_0
|
|
94
76
|
SAFARI_DIRECTIVES = DIRECTIVES_1_0
|
|
77
|
+
SAFARI_10_DIRECTIVES = DIRECTIVES_2_0
|
|
95
78
|
|
|
96
79
|
FIREFOX_UNSUPPORTED_DIRECTIVES = [
|
|
97
80
|
BLOCK_ALL_MIXED_CONTENT,
|
|
@@ -99,25 +82,53 @@ module SecureHeaders
|
|
|
99
82
|
PLUGIN_TYPES
|
|
100
83
|
].freeze
|
|
101
84
|
|
|
85
|
+
FIREFOX_46_DEPRECATED_DIRECTIVES = [
|
|
86
|
+
FRAME_SRC
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
FIREFOX_46_UNSUPPORTED_DIRECTIVES = [
|
|
90
|
+
BLOCK_ALL_MIXED_CONTENT,
|
|
91
|
+
PLUGIN_TYPES
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
102
94
|
FIREFOX_DIRECTIVES = (
|
|
103
|
-
|
|
95
|
+
DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
|
|
96
|
+
).freeze
|
|
97
|
+
|
|
98
|
+
FIREFOX_46_DIRECTIVES = (
|
|
99
|
+
DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
|
|
104
100
|
).freeze
|
|
105
101
|
|
|
106
102
|
CHROME_DIRECTIVES = (
|
|
107
|
-
|
|
103
|
+
DIRECTIVES_3_0
|
|
108
104
|
).freeze
|
|
109
105
|
|
|
110
|
-
ALL_DIRECTIVES =
|
|
106
|
+
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
|
|
111
107
|
|
|
112
108
|
# Think of default-src and report-uri as the beginning and end respectively,
|
|
113
109
|
# everything else is in between.
|
|
114
110
|
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
|
|
115
111
|
|
|
112
|
+
# These are directives that do not inherit the default-src value. This is
|
|
113
|
+
# useful when calling #combine_policies.
|
|
114
|
+
NON_FETCH_SOURCES = [
|
|
115
|
+
BASE_URI,
|
|
116
|
+
FORM_ACTION,
|
|
117
|
+
FRAME_ANCESTORS,
|
|
118
|
+
PLUGIN_TYPES,
|
|
119
|
+
REPORT_URI
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES
|
|
123
|
+
|
|
116
124
|
VARIATIONS = {
|
|
117
125
|
"Chrome" => CHROME_DIRECTIVES,
|
|
118
126
|
"Opera" => CHROME_DIRECTIVES,
|
|
119
127
|
"Firefox" => FIREFOX_DIRECTIVES,
|
|
128
|
+
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
|
|
120
129
|
"Safari" => SAFARI_DIRECTIVES,
|
|
130
|
+
"SafariTransitional" => SAFARI_10_DIRECTIVES,
|
|
131
|
+
"Edge" => EDGE_DIRECTIVES,
|
|
121
132
|
"Other" => CHROME_DIRECTIVES
|
|
122
133
|
}.freeze
|
|
123
134
|
|
|
@@ -138,15 +149,14 @@ module SecureHeaders
|
|
|
138
149
|
MEDIA_SRC => :source_list,
|
|
139
150
|
OBJECT_SRC => :source_list,
|
|
140
151
|
PLUGIN_TYPES => :source_list,
|
|
141
|
-
REFLECTED_XSS => :string,
|
|
142
152
|
REPORT_URI => :source_list,
|
|
143
|
-
SANDBOX => :
|
|
153
|
+
SANDBOX => :source_list,
|
|
144
154
|
SCRIPT_SRC => :source_list,
|
|
145
155
|
STYLE_SRC => :source_list,
|
|
146
156
|
UPGRADE_INSECURE_REQUESTS => :boolean
|
|
147
157
|
}.freeze
|
|
148
158
|
|
|
149
|
-
|
|
159
|
+
|
|
150
160
|
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
|
151
161
|
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
|
152
162
|
|
|
@@ -163,6 +173,11 @@ module SecureHeaders
|
|
|
163
173
|
:preserve_schemes
|
|
164
174
|
].freeze
|
|
165
175
|
|
|
176
|
+
NONCES = [
|
|
177
|
+
:script_nonce,
|
|
178
|
+
:style_nonce
|
|
179
|
+
].freeze
|
|
180
|
+
|
|
166
181
|
module ClassMethods
|
|
167
182
|
# Public: generate a header name, value array that is user-agent-aware.
|
|
168
183
|
#
|
|
@@ -178,9 +193,11 @@ module SecureHeaders
|
|
|
178
193
|
# Does not validate the invididual values of the source expression (e.g.
|
|
179
194
|
# script_src => h*t*t*p: will not raise an exception)
|
|
180
195
|
def validate_config!(config)
|
|
181
|
-
return if config.nil? || config
|
|
182
|
-
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config
|
|
183
|
-
|
|
196
|
+
return if config.nil? || config.opt_out?
|
|
197
|
+
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src)
|
|
198
|
+
ContentSecurityPolicyConfig.attrs.each do |key|
|
|
199
|
+
value = config.directive_value(key)
|
|
200
|
+
next unless value
|
|
184
201
|
if META_CONFIGS.include?(key)
|
|
185
202
|
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
|
186
203
|
else
|
|
@@ -189,16 +206,13 @@ module SecureHeaders
|
|
|
189
206
|
end
|
|
190
207
|
end
|
|
191
208
|
|
|
192
|
-
# Public:
|
|
193
|
-
# actual value of the config.
|
|
209
|
+
# Public: check if a user agent supports CSP nonces
|
|
194
210
|
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return false if config == OPT_OUT
|
|
201
|
-
config == combine_policies(config, additions)
|
|
211
|
+
# user_agent - a String or a UserAgent object
|
|
212
|
+
def nonces_supported?(user_agent)
|
|
213
|
+
user_agent = UserAgent.parse(user_agent) if user_agent.is_a?(String)
|
|
214
|
+
MODERN_BROWSERS.include?(user_agent.browser) ||
|
|
215
|
+
user_agent.browser == "Safari" && (user_agent.version || CSP::FALLBACK_VERSION) >= CSP::VERSION_10
|
|
202
216
|
end
|
|
203
217
|
|
|
204
218
|
# Public: combine the values from two different configs.
|
|
@@ -215,7 +229,7 @@ module SecureHeaders
|
|
|
215
229
|
# 3. if a value in additions does exist in the original config, the two
|
|
216
230
|
# values are joined.
|
|
217
231
|
def combine_policies(original, additions)
|
|
218
|
-
if original ==
|
|
232
|
+
if original == {}
|
|
219
233
|
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
|
220
234
|
end
|
|
221
235
|
|
|
@@ -251,9 +265,30 @@ module SecureHeaders
|
|
|
251
265
|
# copy the default-src value to the original config. This modifies the original hash.
|
|
252
266
|
def populate_fetch_source_with_default!(original, additions)
|
|
253
267
|
# in case we would be appending to an empty directive, fill it with the default-src value
|
|
254
|
-
additions.
|
|
255
|
-
|
|
256
|
-
original
|
|
268
|
+
additions.each_key do |directive|
|
|
269
|
+
if !original[directive] && ((source_list?(directive) && FETCH_SOURCES.include?(directive)) || nonce_added?(original, additions))
|
|
270
|
+
if nonce_added?(original, additions)
|
|
271
|
+
inferred_directive = directive.to_s.gsub(/_nonce/, "_src").to_sym
|
|
272
|
+
unless original[inferred_directive] || NON_FETCH_SOURCES.include?(inferred_directive)
|
|
273
|
+
original[inferred_directive] = default_for(directive, original)
|
|
274
|
+
end
|
|
275
|
+
else
|
|
276
|
+
original[directive] = default_for(directive, original)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def default_for(directive, original)
|
|
283
|
+
return original[FRAME_SRC] if directive == CHILD_SRC && original[FRAME_SRC]
|
|
284
|
+
return original[CHILD_SRC] if directive == FRAME_SRC && original[CHILD_SRC]
|
|
285
|
+
original[DEFAULT_SRC]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def nonce_added?(original, additions)
|
|
289
|
+
[:script_nonce, :style_nonce].each do |nonce|
|
|
290
|
+
if additions[nonce] && !original[nonce]
|
|
291
|
+
return true
|
|
257
292
|
end
|
|
258
293
|
end
|
|
259
294
|
end
|
|
@@ -305,9 +340,9 @@ module SecureHeaders
|
|
|
305
340
|
end
|
|
306
341
|
|
|
307
342
|
def ensure_valid_sources!(directive, source_expression)
|
|
308
|
-
source_expression.each do |
|
|
309
|
-
if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(
|
|
310
|
-
raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{
|
|
343
|
+
source_expression.each do |expression|
|
|
344
|
+
if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(expression)
|
|
345
|
+
raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{expression}). This value must be single quoted.")
|
|
311
346
|
end
|
|
312
347
|
end
|
|
313
348
|
end
|