secure_headers 3.4.1 → 3.5.0.pre
Sign up to get free protection for your applications and to get access to all the features.
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
|
|