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.

@@ -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 = Configuration.send(:deep_copy, config || DEFAULT_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
- @report_only = @config[:report_only]
21
- @preserve_schemes = @config[:preserve_schemes]
22
- @script_nonce = @config[:script_nonce]
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
- if @report_only
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[:frame_src] && @config[:child_src] && @config[:frame_src] != @config[:child_src]
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[:frame_src]
55
- Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config[:frame_src]}.")
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[:child_src] = @config[:child_src] || @config[:frame_src]
65
+ @config.child_src || @config.frame_src
60
66
  else
61
- @config[:frame_src] = @config[:frame_src] || @config[:child_src]
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[directive_name]
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[directive_name]].join(" ")
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
- return if @config[directive].nil?
92
-
93
- source_list = @config[directive].compact
94
- return if source_list.empty?
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!(directive, source_list)
108
- reject_all_values_if_none!(source_list)
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!(source_list)
126
+ source_list = strip_source_schemes(source_list)
112
127
  end
113
- dedup_source_list(source_list).join(" ")
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!(source_list)
124
- source_list.reject! { |value| value == NONE } if source_list.length > 1
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!(directive, source_list)
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
- [DEFAULT_SRC,
196
+ [
197
+ DEFAULT_SRC,
174
198
  BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
175
- REPORT_URI].flatten.select { |directive| @config.key?(directive) }
199
+ REPORT_URI
200
+ ].flatten
176
201
  end
177
202
 
178
203
  # Private: Remove scheme from source expressions.
179
- def strip_source_schemes!(source_list)
180
- source_list.map! { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
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 => :string,
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
- CONFIG_KEY = :csp
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 == OPT_OUT
200
- raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
201
- config.each do |key, value|
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 == OPT_OUT
228
+ if original == {}
237
229
  raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
238
230
  end
239
231
 
@@ -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.1"
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, :dynamic_csp, :cookies].each do |key|
40
- expect(config.send(key)).to eq(noop.send(key)), "Value not copied: #{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 eq(default.csp)
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(ContentSecurityPolicy::HEADER_NAME + "-Report-Only") }
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(ContentSecurityPolicy::HEADER_NAME) }
21
+ specify { expect(ContentSecurityPolicy.new(default_opts).name).to eq(ContentSecurityPolicyConfig::HEADER_NAME) }
22
22
  end
23
23
  end
24
24