secure_headers 3.4.1 → 3.5.0.pre
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.
Potentially problematic release.
This version of secure_headers might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +32 -3
- data/lib/secure_headers.rb +122 -56
- data/lib/secure_headers/configuration.rb +56 -35
- data/lib/secure_headers/headers/content_security_policy.rb +60 -35
- data/lib/secure_headers/headers/content_security_policy_config.rb +128 -0
- data/lib/secure_headers/headers/policy_management.rb +13 -21
- data/secure_headers.gemspec +1 -1
- data/spec/lib/secure_headers/configuration_spec.rb +5 -5
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +2 -2
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +25 -34
- data/spec/lib/secure_headers/middleware_spec.rb +1 -1
- data/spec/lib/secure_headers/view_helpers_spec.rb +9 -6
- data/spec/lib/secure_headers_spec.rb +236 -58
- data/spec/spec_helper.rb +1 -1
- metadata +5 -4
@@ -1,8 +1,8 @@
|
|
1
1
|
require_relative 'policy_management'
|
2
|
+
require_relative 'content_security_policy_config'
|
2
3
|
require 'useragent'
|
3
4
|
|
4
5
|
module SecureHeaders
|
5
|
-
class ContentSecurityPolicyConfigError < StandardError; end
|
6
6
|
class ContentSecurityPolicy
|
7
7
|
include PolicyManagement
|
8
8
|
|
@@ -10,28 +10,34 @@ module SecureHeaders
|
|
10
10
|
VERSION_46 = ::UserAgent::Version.new("46")
|
11
11
|
|
12
12
|
def initialize(config = nil, user_agent = OTHER)
|
13
|
-
@config =
|
13
|
+
@config = if config.is_a?(Hash)
|
14
|
+
if config[:report_only]
|
15
|
+
ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
|
16
|
+
else
|
17
|
+
ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
|
18
|
+
end
|
19
|
+
elsif config.nil?
|
20
|
+
ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
|
21
|
+
else
|
22
|
+
config
|
23
|
+
end
|
24
|
+
|
14
25
|
@parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
|
15
26
|
user_agent
|
16
27
|
else
|
17
28
|
UserAgent.parse(user_agent)
|
18
29
|
end
|
19
|
-
normalize_child_frame_src
|
20
|
-
@
|
21
|
-
@
|
22
|
-
@
|
23
|
-
@style_nonce = @config[:style_nonce]
|
30
|
+
@frame_src = normalize_child_frame_src
|
31
|
+
@preserve_schemes = @config.preserve_schemes
|
32
|
+
@script_nonce = @config.script_nonce
|
33
|
+
@style_nonce = @config.style_nonce
|
24
34
|
end
|
25
35
|
|
26
36
|
##
|
27
37
|
# Returns the name to use for the header. Either "Content-Security-Policy" or
|
28
38
|
# "Content-Security-Policy-Report-Only"
|
29
39
|
def name
|
30
|
-
|
31
|
-
REPORT_ONLY
|
32
|
-
else
|
33
|
-
HEADER_NAME
|
34
|
-
end
|
40
|
+
@config.class.const_get(:HEADER_NAME)
|
35
41
|
end
|
36
42
|
|
37
43
|
##
|
@@ -49,16 +55,16 @@ module SecureHeaders
|
|
49
55
|
# frame-src is deprecated, child-src is being implemented. They are
|
50
56
|
# very similar and in most cases, the same value can be used for both.
|
51
57
|
def normalize_child_frame_src
|
52
|
-
if @config
|
58
|
+
if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
|
53
59
|
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.")
|
54
|
-
elsif @config
|
55
|
-
Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config
|
60
|
+
elsif @config.frame_src
|
61
|
+
Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config.frame_src}.")
|
56
62
|
end
|
57
63
|
|
58
64
|
if supported_directives.include?(:child_src)
|
59
|
-
@config
|
65
|
+
@config.child_src || @config.frame_src
|
60
66
|
else
|
61
|
-
@config
|
67
|
+
@config.frame_src || @config.child_src
|
62
68
|
end
|
63
69
|
end
|
64
70
|
|
@@ -73,9 +79,9 @@ module SecureHeaders
|
|
73
79
|
directives.map do |directive_name|
|
74
80
|
case DIRECTIVE_VALUE_TYPES[directive_name]
|
75
81
|
when :boolean
|
76
|
-
symbol_to_hyphen_case(directive_name) if @config
|
82
|
+
symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
|
77
83
|
when :string
|
78
|
-
[symbol_to_hyphen_case(directive_name), @config
|
84
|
+
[symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ")
|
79
85
|
else
|
80
86
|
build_directive(directive_name)
|
81
87
|
end
|
@@ -88,11 +94,19 @@ module SecureHeaders
|
|
88
94
|
#
|
89
95
|
# Returns a string representing a directive.
|
90
96
|
def build_directive(directive)
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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?
|
96
110
|
normalized_source_list = minify_source_list(directive, source_list)
|
97
111
|
[symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
|
98
112
|
end
|
@@ -101,16 +115,17 @@ module SecureHeaders
|
|
101
115
|
# If a directive contains 'none' but has other values, 'none' is ommitted.
|
102
116
|
# Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
|
103
117
|
def minify_source_list(directive, source_list)
|
118
|
+
source_list = source_list.compact
|
104
119
|
if source_list.include?(STAR)
|
105
120
|
keep_wildcard_sources(source_list)
|
106
121
|
else
|
107
|
-
populate_nonces
|
108
|
-
reject_all_values_if_none
|
122
|
+
source_list = populate_nonces(directive, source_list)
|
123
|
+
source_list = reject_all_values_if_none(source_list)
|
109
124
|
|
110
125
|
unless directive == REPORT_URI || @preserve_schemes
|
111
|
-
strip_source_schemes
|
126
|
+
source_list = strip_source_schemes(source_list)
|
112
127
|
end
|
113
|
-
dedup_source_list(source_list)
|
128
|
+
dedup_source_list(source_list)
|
114
129
|
end
|
115
130
|
end
|
116
131
|
|
@@ -120,8 +135,12 @@ module SecureHeaders
|
|
120
135
|
end
|
121
136
|
|
122
137
|
# Discard any 'none' values if more directives are supplied since none may override values.
|
123
|
-
def reject_all_values_if_none
|
124
|
-
|
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
|
125
144
|
end
|
126
145
|
|
127
146
|
# Removes duplicates and sources that already match an existing wild card.
|
@@ -143,12 +162,14 @@ module SecureHeaders
|
|
143
162
|
|
144
163
|
# Private: append a nonce to the script/style directories if script_nonce
|
145
164
|
# or style_nonce are provided.
|
146
|
-
def populate_nonces
|
165
|
+
def populate_nonces(directive, source_list)
|
147
166
|
case directive
|
148
167
|
when SCRIPT_SRC
|
149
168
|
append_nonce(source_list, @script_nonce)
|
150
169
|
when STYLE_SRC
|
151
170
|
append_nonce(source_list, @style_nonce)
|
171
|
+
else
|
172
|
+
source_list
|
152
173
|
end
|
153
174
|
end
|
154
175
|
|
@@ -165,19 +186,23 @@ module SecureHeaders
|
|
165
186
|
source_list << UNSAFE_INLINE
|
166
187
|
end
|
167
188
|
end
|
189
|
+
|
190
|
+
source_list
|
168
191
|
end
|
169
192
|
|
170
193
|
# Private: return the list of directives that are supported by the user agent,
|
171
194
|
# starting with default-src and ending with report-uri.
|
172
195
|
def directives
|
173
|
-
[
|
196
|
+
[
|
197
|
+
DEFAULT_SRC,
|
174
198
|
BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
|
175
|
-
REPORT_URI
|
199
|
+
REPORT_URI
|
200
|
+
].flatten
|
176
201
|
end
|
177
202
|
|
178
203
|
# Private: Remove scheme from source expressions.
|
179
|
-
def strip_source_schemes
|
180
|
-
source_list.map
|
204
|
+
def strip_source_schemes(source_list)
|
205
|
+
source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
|
181
206
|
end
|
182
207
|
|
183
208
|
# Private: determine which directives are supported for the given user agent.
|
@@ -0,0 +1,128 @@
|
|
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
|
+
value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
|
10
|
+
prev_value = self.instance_variable_get("@#{attr}")
|
11
|
+
self.instance_variable_set("@#{attr}", value)
|
12
|
+
if prev_value != self.instance_variable_get("@#{attr}")
|
13
|
+
@modified = true
|
14
|
+
end
|
15
|
+
else
|
16
|
+
raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(hash)
|
23
|
+
from_hash(hash)
|
24
|
+
@modified = false
|
25
|
+
end
|
26
|
+
|
27
|
+
def update_directive(directive, value)
|
28
|
+
self.send("#{directive}=", value)
|
29
|
+
end
|
30
|
+
|
31
|
+
def directive_value(directive)
|
32
|
+
if self.class.attrs.include?(directive)
|
33
|
+
self.send(directive)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def modified?
|
38
|
+
@modified
|
39
|
+
end
|
40
|
+
|
41
|
+
def merge(new_hash)
|
42
|
+
ContentSecurityPolicy.combine_policies(self.to_h, new_hash)
|
43
|
+
end
|
44
|
+
|
45
|
+
def merge!(new_hash)
|
46
|
+
from_hash(new_hash)
|
47
|
+
end
|
48
|
+
|
49
|
+
def append(new_hash)
|
50
|
+
from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash))
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_h
|
54
|
+
self.class.attrs.each_with_object({}) do |key, hash|
|
55
|
+
hash[key] = self.send(key)
|
56
|
+
end.reject { |_, v| v.nil? }
|
57
|
+
end
|
58
|
+
|
59
|
+
def dup
|
60
|
+
self.class.new(self.to_h)
|
61
|
+
end
|
62
|
+
|
63
|
+
def opt_out?
|
64
|
+
false
|
65
|
+
end
|
66
|
+
|
67
|
+
def ==(o)
|
68
|
+
self.class == o.class && self.to_h == o.to_h
|
69
|
+
end
|
70
|
+
|
71
|
+
alias_method :[], :directive_value
|
72
|
+
alias_method :[]=, :update_directive
|
73
|
+
|
74
|
+
private
|
75
|
+
def from_hash(hash)
|
76
|
+
hash.keys.reject { |k| hash[k].nil? }.map do |k|
|
77
|
+
if self.class.attrs.include?(k)
|
78
|
+
self.send("#{k}=", hash[k])
|
79
|
+
else
|
80
|
+
raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{hash[k]}"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
class ContentSecurityPolicyConfigError < StandardError; end
|
87
|
+
class ContentSecurityPolicyConfig
|
88
|
+
CONFIG_KEY = :csp
|
89
|
+
HEADER_NAME = "Content-Security-Policy".freeze
|
90
|
+
|
91
|
+
def self.attrs
|
92
|
+
PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
|
93
|
+
end
|
94
|
+
|
95
|
+
include DynamicConfig
|
96
|
+
|
97
|
+
# based on what was suggested in https://github.com/rails/rails/pull/24961/files
|
98
|
+
DEFAULT = {
|
99
|
+
default_src: %w('self' https:),
|
100
|
+
font_src: %w('self' https: data:),
|
101
|
+
img_src: %w('self' https: data:),
|
102
|
+
object_src: %w('none'),
|
103
|
+
script_src: %w(https:),
|
104
|
+
style_src: %w('self' https: 'unsafe-inline')
|
105
|
+
}
|
106
|
+
|
107
|
+
def report_only?
|
108
|
+
false
|
109
|
+
end
|
110
|
+
|
111
|
+
def make_report_only
|
112
|
+
ContentSecurityPolicyReportOnlyConfig.new(self.to_h)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig
|
117
|
+
CONFIG_KEY = :csp_report_only
|
118
|
+
HEADER_NAME = "Content-Security-Policy-Report-Only".freeze
|
119
|
+
|
120
|
+
def report_only?
|
121
|
+
true
|
122
|
+
end
|
123
|
+
|
124
|
+
def make_report_only
|
125
|
+
self
|
126
|
+
end
|
127
|
+
end
|
128
|
+
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
|
@@ -158,13 +155,13 @@ module SecureHeaders
|
|
158
155
|
PLUGIN_TYPES => :source_list,
|
159
156
|
REFLECTED_XSS => :string,
|
160
157
|
REPORT_URI => :source_list,
|
161
|
-
SANDBOX => :
|
158
|
+
SANDBOX => :source_list,
|
162
159
|
SCRIPT_SRC => :source_list,
|
163
160
|
STYLE_SRC => :source_list,
|
164
161
|
UPGRADE_INSECURE_REQUESTS => :boolean
|
165
162
|
}.freeze
|
166
163
|
|
167
|
-
|
164
|
+
|
168
165
|
STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
|
169
166
|
HTTP_SCHEME_REGEX = %r{\Ahttps?://}
|
170
167
|
|
@@ -181,6 +178,11 @@ module SecureHeaders
|
|
181
178
|
:preserve_schemes
|
182
179
|
].freeze
|
183
180
|
|
181
|
+
NONCES = [
|
182
|
+
:script_nonce,
|
183
|
+
:style_nonce
|
184
|
+
].freeze
|
185
|
+
|
184
186
|
module ClassMethods
|
185
187
|
# Public: generate a header name, value array that is user-agent-aware.
|
186
188
|
#
|
@@ -196,9 +198,11 @@ module SecureHeaders
|
|
196
198
|
# Does not validate the invididual values of the source expression (e.g.
|
197
199
|
# script_src => h*t*t*p: will not raise an exception)
|
198
200
|
def validate_config!(config)
|
199
|
-
return if config.nil? || config
|
200
|
-
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config
|
201
|
-
|
201
|
+
return if config.nil? || config.opt_out?
|
202
|
+
raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src)
|
203
|
+
ContentSecurityPolicyConfig.attrs.each do |key|
|
204
|
+
value = config.directive_value(key)
|
205
|
+
next unless value
|
202
206
|
if META_CONFIGS.include?(key)
|
203
207
|
raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
|
204
208
|
else
|
@@ -207,18 +211,6 @@ module SecureHeaders
|
|
207
211
|
end
|
208
212
|
end
|
209
213
|
|
210
|
-
# Public: determine if merging +additions+ will cause a change to the
|
211
|
-
# actual value of the config.
|
212
|
-
#
|
213
|
-
# e.g. config = { script_src: %w(example.org google.com)} and
|
214
|
-
# additions = { script_src: %w(google.com)} then idempotent_additions? would return
|
215
|
-
# because google.com is already in the config.
|
216
|
-
def idempotent_additions?(config, additions)
|
217
|
-
return true if config == OPT_OUT && additions == OPT_OUT
|
218
|
-
return false if config == OPT_OUT
|
219
|
-
config == combine_policies(config, additions)
|
220
|
-
end
|
221
|
-
|
222
214
|
# Public: combine the values from two different configs.
|
223
215
|
#
|
224
216
|
# original - the main config
|
@@ -233,7 +225,7 @@ module SecureHeaders
|
|
233
225
|
# 3. if a value in additions does exist in the original config, the two
|
234
226
|
# values are joined.
|
235
227
|
def combine_policies(original, additions)
|
236
|
-
if original ==
|
228
|
+
if original == {}
|
237
229
|
raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
|
238
230
|
end
|
239
231
|
|
data/secure_headers.gemspec
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
2
2
|
Gem::Specification.new do |gem|
|
3
3
|
gem.name = "secure_headers"
|
4
|
-
gem.version = "3.
|
4
|
+
gem.version = "3.5.0.pre"
|
5
5
|
gem.authors = ["Neil Matatall"]
|
6
6
|
gem.email = ["neil.matatall@gmail.com"]
|
7
7
|
gem.description = 'Security related headers all in one gem.'
|
@@ -36,8 +36,8 @@ module SecureHeaders
|
|
36
36
|
|
37
37
|
config = Configuration.get(:test_override)
|
38
38
|
noop = Configuration.get(Configuration::NOOP_CONFIGURATION)
|
39
|
-
[:csp, :
|
40
|
-
expect(config.send(key)).to eq(noop.send(key))
|
39
|
+
[:csp, :csp_report_only, :cookies].each do |key|
|
40
|
+
expect(config.send(key)).to eq(noop.send(key))
|
41
41
|
end
|
42
42
|
end
|
43
43
|
|
@@ -65,7 +65,7 @@ module SecureHeaders
|
|
65
65
|
default = Configuration.get
|
66
66
|
override = Configuration.get(:override)
|
67
67
|
|
68
|
-
expect(override.csp).not_to
|
68
|
+
expect(override.csp.directive_value(:default_src)).not_to be(default.csp.directive_value(:default_src))
|
69
69
|
end
|
70
70
|
|
71
71
|
it "allows you to override an override" do
|
@@ -78,9 +78,9 @@ module SecureHeaders
|
|
78
78
|
end
|
79
79
|
|
80
80
|
original_override = Configuration.get(:override)
|
81
|
-
expect(original_override.csp).to eq(default_src: %w('self'))
|
81
|
+
expect(original_override.csp.to_h).to eq(default_src: %w('self'))
|
82
82
|
override_config = Configuration.get(:second_override)
|
83
|
-
expect(override_config.csp).to eq(default_src: %w('self'), script_src: %w(example.org))
|
83
|
+
expect(override_config.csp.to_h).to eq(default_src: %w('self'), script_src: %w('self' example.org))
|
84
84
|
end
|
85
85
|
|
86
86
|
it "deprecates the secure_cookies configuration" do
|
@@ -14,11 +14,11 @@ module SecureHeaders
|
|
14
14
|
|
15
15
|
describe "#name" do
|
16
16
|
context "when in report-only mode" do
|
17
|
-
specify { expect(ContentSecurityPolicy.new(default_opts.merge(report_only: true)).name).to eq(
|
17
|
+
specify { expect(ContentSecurityPolicy.new(default_opts.merge(report_only: true)).name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME) }
|
18
18
|
end
|
19
19
|
|
20
20
|
context "when in enforce mode" do
|
21
|
-
specify { expect(ContentSecurityPolicy.new(default_opts).name).to eq(
|
21
|
+
specify { expect(ContentSecurityPolicy.new(default_opts).name).to eq(ContentSecurityPolicyConfig::HEADER_NAME) }
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|