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.

@@ -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