secure_headers 3.4.1 → 3.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 45e392304147a02bee8cf0709fe52e16f9ad255c
4
- data.tar.gz: e07030aa93c84a20e9b22d88af6489d8a71c95e7
3
+ metadata.gz: f47871f10c97316165d3f4230ddae85935d9a736
4
+ data.tar.gz: f5777312e07643e054d48b4121ba30a905aab4dd
5
5
  SHA512:
6
- metadata.gz: 8238f728eb74303b6aac54d40e3eaf1aacdaf7e0c910fe9a05f3dc005daa9eb96876391619eb2cb613a0d2a8ad358a48bc899626be46d201561f05064f47fdf8
7
- data.tar.gz: d0d448274a7a4789f668ac59c49903bf50889a2675a82d62d91ca721c5160b50288b5fafcf8b041422f575cc42d31b4a614fbc8036759fb522c149131a472f33
6
+ metadata.gz: 8516799304bf21ea8d67aace99325df8c34d1e3d72082b44ad4a80c658ddc9cee6e4a04fc8202ac062044c0abbc91ce870fc26a472960f390fe359670f7362cf
7
+ data.tar.gz: d4fc7f5482fc8e904ce3ea80340da5c5426b87c88bbb8f669af9e050b212a1189b997de0c3c6bdc9a3b4df58c69efa54598ea40caaf56eb9bc69a80722029f8c
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.2.3
1
+ 2.2.5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 3.5.0
2
+
3
+ This release adds support for setting two CSP headers (enforced/report-only) and management around them.
4
+
5
+
1
6
  ## 3.4.1 Named Appends
2
7
 
3
8
  ### Small bugfix
data/Gemfile CHANGED
@@ -9,6 +9,7 @@ group :test do
9
9
  gem "rack", "~> 1"
10
10
  gem "rspec"
11
11
  gem "coveralls"
12
+ gem "term-ansicolor", "< 1.4"
12
13
  end
13
14
 
14
15
  group :guard do
data/README.md CHANGED
@@ -20,10 +20,6 @@ It can also mark all http cookies with the Secure, HttpOnly and SameSite attribu
20
20
 
21
21
  `secure_headers` is a library with a global config, per request overrides, and rack middleware that enables you customize your application settings.
22
22
 
23
- ## Use
24
-
25
- `gem install secure_headers`
26
-
27
23
  ## Configuration
28
24
 
29
25
  If you do not supply a `default` configuration, exceptions will be raised. If you would like to use a default configuration (which is fairly locked down), just call `SecureHeaders::Configuration.default` without any arguments or block.
@@ -36,7 +32,7 @@ SecureHeaders::Configuration.default do |config|
36
32
  secure: true, # mark all cookies as "Secure"
37
33
  httponly: true, # mark all cookies as "HttpOnly"
38
34
  samesite: {
39
- strict: true # mark all cookies as SameSite=Strict
35
+ lax: true # mark all cookies as SameSite=lax
40
36
  }
41
37
  }
42
38
  config.hsts = "max-age=#{20.years.to_i}; includeSubdomains; preload"
@@ -48,7 +44,7 @@ SecureHeaders::Configuration.default do |config|
48
44
  config.referrer_policy = "origin-when-cross-origin"
49
45
  config.csp = {
50
46
  # "meta" values. these will shaped the header, but the values are not included in the header.
51
- report_only: true, # default: false
47
+ report_only: true, # default: false [DEPRECATED from 3.5.0: instead, configure csp_report_only]
52
48
  preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.
53
49
 
54
50
  # directive values: these values will directly translate into source directives
@@ -69,6 +65,11 @@ SecureHeaders::Configuration.default do |config|
69
65
  upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/
70
66
  report_uri: %w(https://report-uri.io/example-csp)
71
67
  }
68
+ # This is available only from 3.5.0; use the `report_only: true` setting for 3.4.1 and below.
69
+ config.csp_report_only = config.csp.merge({
70
+ img_src: %w(somewhereelse.com),
71
+ report_uri: %w(https://report-uri.io/example-csp-report-only)
72
+ })
72
73
  config.hpkp = {
73
74
  report_only: false,
74
75
  max_age: 60.days.to_i,
@@ -92,7 +93,30 @@ use SecureHeaders::Middleware
92
93
 
93
94
  ## Default values
94
95
 
95
- All headers except for PublicKeyPins have a default value. See the [corresponding classes for their defaults](https://github.com/twitter/secureheaders/tree/master/lib/secure_headers/headers).
96
+ All headers except for PublicKeyPins have a default value. See the [corresponding classes for their defaults](https://github.com/twitter/secureheaders/tree/master/lib/secure_headers/headers). The default set of headers is:
97
+
98
+ ```
99
+ Content-Security-Policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'
100
+ Strict-Transport-Security: max-age=631138519
101
+ X-Content-Type-Options: nosniff
102
+ X-Download-Options: noopen
103
+ X-Frame-Options: sameorigin
104
+ X-Permitted-Cross-Domain-Policies: none
105
+ X-Xss-Protection: 1; mode=block
106
+ ```
107
+
108
+ ### Default CSP
109
+
110
+ By default, the above CSP will be applied to all requests. If you **only** want to set a Report-Only header, opt-out of the default enforced header for clarity. The configuration will assume that if you only supply `csp_report_only` that you intended to opt-out of `csp` but that's for the sake of backwards compatibility and it will be removed in the future.
111
+
112
+ ```ruby
113
+ Configuration.default do |config|
114
+ config.csp = SecureHeaders::OPT_OUT # If this line is omitted, we will assume you meant to opt out.
115
+ config.csp_report_only = {
116
+ default_src: %w('self')
117
+ }
118
+ end
119
+ ```
96
120
 
97
121
  ## Named Appends
98
122
 
@@ -85,7 +85,6 @@ module SecureHeaders
85
85
  ALL_HEADER_CLASSES.each do |klass|
86
86
  config.send("#{klass::CONFIG_KEY}=", OPT_OUT)
87
87
  end
88
- config.dynamic_csp = OPT_OUT
89
88
  end
90
89
 
91
90
  add_configuration(NOOP_CONFIGURATION, noop_config)
@@ -94,6 +93,7 @@ module SecureHeaders
94
93
  # Public: perform a basic deep dup. The shallow copy provided by dup/clone
95
94
  # can lead to modifying parent objects.
96
95
  def deep_copy(config)
96
+ return unless config
97
97
  config.each_with_object({}) do |(key, value), hash|
98
98
  hash[key] = if value.is_a?(Array)
99
99
  value.dup
@@ -114,13 +114,11 @@ module SecureHeaders
114
114
  end
115
115
  end
116
116
 
117
- attr_accessor :dynamic_csp
118
-
119
117
  attr_writer :hsts, :x_frame_options, :x_content_type_options,
120
118
  :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
121
119
  :referrer_policy
122
120
 
123
- attr_reader :cached_headers, :csp, :cookies, :hpkp, :hpkp_report_host
121
+ attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
124
122
 
125
123
  HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
126
124
  if File.exists?(HASH_CONFIG_FILE)
@@ -132,7 +130,8 @@ module SecureHeaders
132
130
  def initialize(&block)
133
131
  self.hpkp = OPT_OUT
134
132
  self.referrer_policy = OPT_OUT
135
- self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG)
133
+ self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
134
+ self.csp_report_only = OPT_OUT
136
135
  instance_eval &block if block_given?
137
136
  end
138
137
 
@@ -141,9 +140,9 @@ module SecureHeaders
141
140
  # Returns a deep-dup'd copy of this configuration.
142
141
  def dup
143
142
  copy = self.class.new
144
- copy.cookies = @cookies
145
- copy.csp = self.class.send(:deep_copy_if_hash, @csp)
146
- copy.dynamic_csp = self.class.send(:deep_copy_if_hash, @dynamic_csp)
143
+ copy.cookies = self.class.send(:deep_copy_if_hash, @cookies)
144
+ copy.csp = @csp.dup if @csp
145
+ copy.csp_report_only = @csp_report_only.dup if @csp_report_only
147
146
  copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
148
147
  copy.x_content_type_options = @x_content_type_options
149
148
  copy.hsts = @hsts
@@ -159,9 +158,6 @@ module SecureHeaders
159
158
 
160
159
  def opt_out(header)
161
160
  send("#{header}=", OPT_OUT)
162
- if header == CSP::CONFIG_KEY
163
- dynamic_csp = OPT_OUT
164
- end
165
161
  self.cached_headers.delete(header)
166
162
  end
167
163
 
@@ -170,20 +166,6 @@ module SecureHeaders
170
166
  self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
171
167
  end
172
168
 
173
- # Public: generated cached headers for a specific user agent.
174
- def rebuild_csp_header_cache!(user_agent)
175
- self.cached_headers[CSP::CONFIG_KEY] = {}
176
- unless current_csp == OPT_OUT
177
- user_agent = UserAgent.parse(user_agent)
178
- variation = CSP.ua_to_variation(user_agent)
179
- self.cached_headers[CSP::CONFIG_KEY][variation] = CSP.make_header(current_csp, user_agent)
180
- end
181
- end
182
-
183
- def current_csp
184
- @dynamic_csp || @csp
185
- end
186
-
187
169
  # Public: validates all configurations values.
188
170
  #
189
171
  # Raises various configuration errors if any invalid config is detected.
@@ -192,6 +174,7 @@ module SecureHeaders
192
174
  def validate_config!
193
175
  StrictTransportSecurity.validate_config!(@hsts)
194
176
  ContentSecurityPolicy.validate_config!(@csp)
177
+ ContentSecurityPolicy.validate_config!(@csp_report_only)
195
178
  ReferrerPolicy.validate_config!(@referrer_policy)
196
179
  XFrameOptions.validate_config!(@x_frame_options)
197
180
  XContentTypeOptions.validate_config!(@x_content_type_options)
@@ -207,16 +190,50 @@ module SecureHeaders
207
190
  @cookies = (@cookies || {}).merge(secure: secure_cookies)
208
191
  end
209
192
 
210
- protected
211
-
212
193
  def csp=(new_csp)
213
- if self.dynamic_csp
214
- raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= instead."
194
+ if new_csp.respond_to?(:opt_out?)
195
+ @csp = new_csp.dup
196
+ else
197
+ if new_csp[:report_only]
198
+ # Deprecated configuration implies that CSPRO should be set, CSP should not - so opt out
199
+ Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp=` was supplied a config with report_only: true. Use #csp_report_only="
200
+ @csp = OPT_OUT
201
+ self.csp_report_only = new_csp
202
+ else
203
+ @csp = ContentSecurityPolicyConfig.new(new_csp)
204
+ end
205
+ end
206
+ end
207
+
208
+ # Configures the Content-Security-Policy-Report-Only header. `new_csp` cannot
209
+ # contain `report_only: false` or an error will be raised.
210
+ #
211
+ # NOTE: if csp has not been configured/has the default value when
212
+ # configuring csp_report_only, the code will assume you mean to only use
213
+ # report-only mode and you will be opted-out of enforce mode.
214
+ def csp_report_only=(new_csp)
215
+ @csp_report_only = begin
216
+ if new_csp.is_a?(ContentSecurityPolicyConfig)
217
+ new_csp.make_report_only
218
+ elsif new_csp.respond_to?(:opt_out?)
219
+ new_csp.dup
220
+ else
221
+ if new_csp[:report_only] == false # nil is a valid value on which we do not want to raise
222
+ raise ContentSecurityPolicyConfigError, "`#csp_report_only=` was supplied a config with report_only: false. Use #csp="
223
+ else
224
+ ContentSecurityPolicyReportOnlyConfig.new(new_csp)
225
+ end
226
+ end
215
227
  end
216
228
 
217
- @csp = self.class.send(:deep_copy_if_hash, new_csp)
229
+ if !@csp_report_only.opt_out? && @csp.to_h == ContentSecurityPolicyConfig::DEFAULT
230
+ Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp_report_only=` was configured before `#csp=`. It is assumed you intended to opt out of `#csp=` so be sure to add `config.csp = SecureHeaders::OPT_OUT` to your config. Ensure that #csp_report_only is configured after #csp="
231
+ @csp = OPT_OUT
232
+ end
218
233
  end
219
234
 
235
+ protected
236
+
220
237
  def cookies=(cookies)
221
238
  @cookies = cookies
222
239
  end
@@ -269,12 +286,16 @@ module SecureHeaders
269
286
  #
270
287
  # Returns nothing
271
288
  def generate_csp_headers(headers)
272
- unless @csp == OPT_OUT
273
- headers[CSP::CONFIG_KEY] = {}
274
- csp_config = self.current_csp
275
- CSP::VARIATIONS.each do |name, _|
276
- csp = CSP.make_header(csp_config, UserAgent.parse(name))
277
- headers[CSP::CONFIG_KEY][name] = csp.freeze
289
+ generate_csp_headers_for_config(headers, ContentSecurityPolicyConfig::CONFIG_KEY, self.csp)
290
+ generate_csp_headers_for_config(headers, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY, self.csp_report_only)
291
+ end
292
+
293
+ def generate_csp_headers_for_config(headers, header_key, csp_config)
294
+ unless csp_config.opt_out?
295
+ headers[header_key] = {}
296
+ ContentSecurityPolicy::VARIATIONS.each do |name, _|
297
+ csp = ContentSecurityPolicy.make_header(csp_config, UserAgent.parse(name))
298
+ headers[header_key][name] = csp.freeze
278
299
  end
279
300
  end
280
301
  end
@@ -1,37 +1,44 @@
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
 
9
9
  # constants to be used for version-specific UA sniffing
10
10
  VERSION_46 = ::UserAgent::Version.new("46")
11
+ VERSION_10 = ::UserAgent::Version.new("10")
11
12
 
12
13
  def initialize(config = nil, user_agent = OTHER)
13
- @config = Configuration.send(:deep_copy, config || DEFAULT_CONFIG)
14
+ @config = if config.is_a?(Hash)
15
+ if config[:report_only]
16
+ ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
17
+ else
18
+ ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
19
+ end
20
+ elsif config.nil?
21
+ ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
22
+ else
23
+ config
24
+ end
25
+
14
26
  @parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
15
27
  user_agent
16
28
  else
17
29
  UserAgent.parse(user_agent)
18
30
  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]
31
+ @frame_src = normalize_child_frame_src
32
+ @preserve_schemes = @config.preserve_schemes
33
+ @script_nonce = @config.script_nonce
34
+ @style_nonce = @config.style_nonce
24
35
  end
25
36
 
26
37
  ##
27
38
  # Returns the name to use for the header. Either "Content-Security-Policy" or
28
39
  # "Content-Security-Policy-Report-Only"
29
40
  def name
30
- if @report_only
31
- REPORT_ONLY
32
- else
33
- HEADER_NAME
34
- end
41
+ @config.class.const_get(:HEADER_NAME)
35
42
  end
36
43
 
37
44
  ##
@@ -49,16 +56,16 @@ module SecureHeaders
49
56
  # frame-src is deprecated, child-src is being implemented. They are
50
57
  # very similar and in most cases, the same value can be used for both.
51
58
  def normalize_child_frame_src
52
- if @config[:frame_src] && @config[:child_src] && @config[:frame_src] != @config[:child_src]
59
+ if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
53
60
  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]}.")
61
+ elsif @config.frame_src
62
+ Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config.frame_src}.")
56
63
  end
57
64
 
58
65
  if supported_directives.include?(:child_src)
59
- @config[:child_src] = @config[:child_src] || @config[:frame_src]
66
+ @config.child_src || @config.frame_src
60
67
  else
61
- @config[:frame_src] = @config[:frame_src] || @config[:child_src]
68
+ @config.frame_src || @config.child_src
62
69
  end
63
70
  end
64
71
 
@@ -73,9 +80,9 @@ module SecureHeaders
73
80
  directives.map do |directive_name|
74
81
  case DIRECTIVE_VALUE_TYPES[directive_name]
75
82
  when :boolean
76
- symbol_to_hyphen_case(directive_name) if @config[directive_name]
83
+ symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
77
84
  when :string
78
- [symbol_to_hyphen_case(directive_name), @config[directive_name]].join(" ")
85
+ [symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ")
79
86
  else
80
87
  build_directive(directive_name)
81
88
  end
@@ -88,11 +95,19 @@ module SecureHeaders
88
95
  #
89
96
  # Returns a string representing a directive.
90
97
  def build_directive(directive)
91
- return if @config[directive].nil?
92
-
93
- source_list = @config[directive].compact
94
- return if source_list.empty?
95
-
98
+ source_list = case directive
99
+ when :child_src
100
+ if supported_directives.include?(:child_src)
101
+ @frame_src
102
+ end
103
+ when :frame_src
104
+ unless supported_directives.include?(:child_src)
105
+ @frame_src
106
+ end
107
+ else
108
+ @config.directive_value(directive)
109
+ end
110
+ return unless source_list && source_list.any?
96
111
  normalized_source_list = minify_source_list(directive, source_list)
97
112
  [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
98
113
  end
@@ -101,16 +116,17 @@ module SecureHeaders
101
116
  # If a directive contains 'none' but has other values, 'none' is ommitted.
102
117
  # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
103
118
  def minify_source_list(directive, source_list)
119
+ source_list = source_list.compact
104
120
  if source_list.include?(STAR)
105
121
  keep_wildcard_sources(source_list)
106
122
  else
107
- populate_nonces!(directive, source_list)
108
- reject_all_values_if_none!(source_list)
123
+ source_list = populate_nonces(directive, source_list)
124
+ source_list = reject_all_values_if_none(source_list)
109
125
 
110
126
  unless directive == REPORT_URI || @preserve_schemes
111
- strip_source_schemes!(source_list)
127
+ source_list = strip_source_schemes(source_list)
112
128
  end
113
- dedup_source_list(source_list).join(" ")
129
+ dedup_source_list(source_list)
114
130
  end
115
131
  end
116
132
 
@@ -120,8 +136,12 @@ module SecureHeaders
120
136
  end
121
137
 
122
138
  # 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
139
+ def reject_all_values_if_none(source_list)
140
+ if source_list.length > 1
141
+ source_list.reject { |value| value == NONE }
142
+ else
143
+ source_list
144
+ end
125
145
  end
126
146
 
127
147
  # Removes duplicates and sources that already match an existing wild card.
@@ -143,12 +163,14 @@ module SecureHeaders
143
163
 
144
164
  # Private: append a nonce to the script/style directories if script_nonce
145
165
  # or style_nonce are provided.
146
- def populate_nonces!(directive, source_list)
166
+ def populate_nonces(directive, source_list)
147
167
  case directive
148
168
  when SCRIPT_SRC
149
169
  append_nonce(source_list, @script_nonce)
150
170
  when STYLE_SRC
151
171
  append_nonce(source_list, @style_nonce)
172
+ else
173
+ source_list
152
174
  end
153
175
  end
154
176
 
@@ -165,19 +187,23 @@ module SecureHeaders
165
187
  source_list << UNSAFE_INLINE
166
188
  end
167
189
  end
190
+
191
+ source_list
168
192
  end
169
193
 
170
194
  # Private: return the list of directives that are supported by the user agent,
171
195
  # starting with default-src and ending with report-uri.
172
196
  def directives
173
- [DEFAULT_SRC,
197
+ [
198
+ DEFAULT_SRC,
174
199
  BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
175
- REPORT_URI].flatten.select { |directive| @config.key?(directive) }
200
+ REPORT_URI
201
+ ].flatten
176
202
  end
177
203
 
178
204
  # 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, "") }
205
+ def strip_source_schemes(source_list)
206
+ source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
181
207
  end
182
208
 
183
209
  # Private: determine which directives are supported for the given user agent.
@@ -198,7 +224,7 @@ module SecureHeaders
198
224
  end
199
225
 
200
226
  def nonces_supported?
201
- @nonces_supported ||= MODERN_BROWSERS.include?(@parsed_ua.browser)
227
+ @nonces_supported ||= self.class.nonces_supported?(@parsed_ua)
202
228
  end
203
229
 
204
230
  def symbol_to_hyphen_case(sym)
@@ -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