secure_headers 3.4.0 → 3.6.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.
@@ -0,0 +1,107 @@
1
+ ## Named Appends
2
+
3
+ Named Appends are blocks of code that can be reused and composed during requests. e.g. If a certain partial is rendered conditionally, and the csp needs to be adjusted for that partial, you can create a named append for that situation. The value returned by the block will be passed into `append_content_security_policy_directives`. The current request object is passed as an argument to the block for even more flexibility.
4
+
5
+ ```ruby
6
+ def show
7
+ if include_widget?
8
+ @widget = widget.render
9
+ use_content_security_policy_named_append(:widget_partial)
10
+ end
11
+ end
12
+
13
+
14
+ SecureHeaders::Configuration.named_append(:widget_partial) do |request|
15
+ SecureHeaders.override_x_frame_options(request, "DENY")
16
+ if request.controller_instance.current_user.in_test_bucket?
17
+ { child_src: %w(beta.thirdpartyhost.com) }
18
+ else
19
+ { child_src: %w(thirdpartyhost.com) }
20
+ end
21
+ end
22
+ ```
23
+
24
+ You can use as many named appends as you would like per request, but be careful because order of inclusion matters. Consider the following:
25
+
26
+ ```ruby
27
+ SecureHeader::Configuration.default do |config|
28
+ config.csp = { default_src: %w('self')}
29
+ end
30
+
31
+ SecureHeaders::Configuration.named_append(:A) do |request|
32
+ { default_src: %w(myhost.com) }
33
+ end
34
+
35
+ SecureHeaders::Configuration.named_append(:B) do |request|
36
+ { script_src: %w('unsafe-eval') }
37
+ end
38
+ ```
39
+
40
+ The following code will produce different policies due to the way policies are normalized (e.g. providing a previously undefined directive that inherits from `default-src`, removing host source values when `*` is provided. Removing `'none'` when additional values are present, etc.):
41
+
42
+ ```ruby
43
+ def index
44
+ use_content_security_policy_named_append(:A)
45
+ use_content_security_policy_named_append(:B)
46
+ # produces default-src 'self' myhost.com; script-src 'self' myhost.com 'unsafe-eval';
47
+ end
48
+
49
+ def show
50
+ use_content_security_policy_named_append(:B)
51
+ use_content_security_policy_named_append(:A)
52
+ # produces default-src 'self' myhost.com; script-src 'self' 'unsafe-eval';
53
+ end
54
+ ```
55
+
56
+
57
+ ## Named overrides
58
+
59
+ Named overrides serve two purposes:
60
+
61
+ * To be able to refer to a configuration by simple name.
62
+ * By precomputing the headers for a named configuration, the headers generated once and reused over every request.
63
+
64
+ To use a named override, drop a `SecureHeaders::Configuration.override` block **outside** of method definitions and then declare which named override you'd like to use. You can even override an override.
65
+
66
+ ```ruby
67
+ class ApplicationController < ActionController::Base
68
+ SecureHeaders::Configuration.default do |config|
69
+ config.csp = {
70
+ default_src: %w('self'),
71
+ script_src: %w(example.org)
72
+ }
73
+ end
74
+
75
+ # override default configuration
76
+ SecureHeaders::Configuration.override(:script_from_otherdomain_com) do |config|
77
+ config.csp[:script_src] << "otherdomain.com"
78
+ end
79
+
80
+ # overrides the :script_from_otherdomain_com configuration
81
+ SecureHeaders::Configuration.override(:another_config, :script_from_otherdomain_com) do |config|
82
+ config.csp[:script_src] << "evenanotherdomain.com"
83
+ end
84
+ end
85
+
86
+ class MyController < ApplicationController
87
+ def index
88
+ # Produces default-src 'self'; script-src example.org otherdomain.com
89
+ use_secure_headers_override(:script_from_otherdomain_com)
90
+ end
91
+
92
+ def show
93
+ # Produces default-src 'self'; script-src example.org otherdomain.org evenanotherdomain.com
94
+ use_secure_headers_override(:another_config)
95
+ end
96
+ end
97
+ ```
98
+
99
+ By default, a no-op configuration is provided. No headers will be set when this default override is used.
100
+
101
+ ```ruby
102
+ class MyController < ApplicationController
103
+ def index
104
+ SecureHeaders.opt_out_of_all_protection(request)
105
+ end
106
+ end
107
+ ```
@@ -0,0 +1,105 @@
1
+ ## Per-action configuration
2
+
3
+ You can override the settings for a given action by producing a temporary override. Be aware that because of the dynamic nature of the value, the header values will be computed per request.
4
+
5
+ ```ruby
6
+ # Given a config of:
7
+ ::SecureHeaders::Configuration.default do |config|
8
+ config.csp = {
9
+ default_src: %w('self'),
10
+ script_src: %w('self')
11
+ }
12
+ end
13
+
14
+ class MyController < ApplicationController
15
+ def index
16
+ # Append value to the source list, override 'none' values
17
+ # Produces: default-src 'self'; script-src 'self' s3.amazonaws.com; object-src 'self' www.youtube.com
18
+ append_content_security_policy_directives(script_src: %w(s3.amazonaws.com), object_src: %w('self' www.youtube.com))
19
+
20
+ # Overrides the previously set source list, override 'none' values
21
+ # Produces: default-src 'self'; script-src s3.amazonaws.com; object-src 'self'
22
+ override_content_security_policy_directives(script_src: %w(s3.amazonaws.com), object_src: %w('self'))
23
+
24
+ # Global settings default to "sameorigin"
25
+ override_x_frame_options("DENY")
26
+ end
27
+ ```
28
+
29
+ The following methods are available as controller instance methods. They are also available as class methods, but require you to pass in the `request` object.
30
+ * `append_content_security_policy_directives(hash)`: appends each value to the corresponding CSP app-wide configuration.
31
+ * `override_content_security_policy_directives(hash)`: merges the hash into the app-wide configuration, overwriting any previous config
32
+ * `override_x_frame_options(value)`: sets the `X-Frame-Options header` to `value`
33
+
34
+ ## Appending / overriding Content Security Policy
35
+
36
+ When manipulating content security policy, there are a few things to consider. The default header value is `default-src https:` which corresponds to a default configuration of `{ default_src: %w(https:)}`.
37
+
38
+ #### Append to the policy with a directive other than `default_src`
39
+
40
+ The value of `default_src` is joined with the addition if the it is a [fetch directive](https://w3c.github.io/webappsec-csp/#directives-fetch). Note the `https:` is carried over from the `default-src` config. If you do not want this, use `override_content_security_policy_directives` instead. To illustrate:
41
+
42
+ ```ruby
43
+ ::SecureHeaders::Configuration.default do |config|
44
+ config.csp = {
45
+ default_src: %w('self')
46
+ }
47
+ end
48
+ ```
49
+
50
+ Code | Result
51
+ ------------- | -------------
52
+ `append_content_security_policy_directives(script_src: %w(mycdn.com))` | `default-src 'self'; script-src 'self' mycdn.com`
53
+ `override_content_security_policy_directives(script_src: %w(mycdn.com))` | `default-src 'self'; script-src mycdn.com`
54
+
55
+ #### Nonce
56
+
57
+ You can use a view helper to automatically add nonces to script tags:
58
+
59
+ ```erb
60
+ <%= nonced_javascript_tag do %>
61
+ console.log("nonced!");
62
+ <% end %>
63
+
64
+ <%= nonced_style_tag do %>
65
+ body {
66
+ background-color: black;
67
+ }
68
+ <% end %>
69
+ ```
70
+
71
+ becomes:
72
+
73
+ ```html
74
+ <script nonce="/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=">
75
+ console.log("nonced!")
76
+ </script>
77
+ <style nonce="/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=">
78
+ body {
79
+ background-color: black;
80
+ }
81
+ </style>
82
+ ```
83
+
84
+ ```
85
+
86
+ Content-Security-Policy: ...
87
+ script-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...;
88
+ style-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...;
89
+ ```
90
+
91
+ `script`/`style-nonce` can be used to whitelist inline content. To do this, call the `content_security_policy_script_nonce` or `content_security_policy_style_nonce` then set the nonce attributes on the various tags.
92
+
93
+ ```erb
94
+ <script nonce="<%= content_security_policy_script_nonce %>">
95
+ console.log("whitelisted, will execute")
96
+ </script>
97
+
98
+ <script nonce="lol">
99
+ console.log("won't execute, not whitelisted")
100
+ </script>
101
+
102
+ <script>
103
+ console.log("won't execute, not whitelisted")
104
+ </script>
105
+ ```
data/docs/sinatra.md ADDED
@@ -0,0 +1,25 @@
1
+ ## Sinatra
2
+
3
+ Here's an example using SecureHeaders for Sinatra applications:
4
+
5
+ ```ruby
6
+ require 'rubygems'
7
+ require 'sinatra'
8
+ require 'haml'
9
+ require 'secure_headers'
10
+
11
+ use SecureHeaders::Middleware
12
+
13
+ SecureHeaders::Configuration.default do |config|
14
+ ...
15
+ end
16
+
17
+ class Donkey < Sinatra::Application
18
+ set :root, APP_ROOT
19
+
20
+ get '/' do
21
+ SecureHeaders.override_x_frame_options(request, SecureHeaders::OPT_OUT)
22
+ haml :index
23
+ end
24
+ end
25
+ ```
@@ -46,6 +46,17 @@ module SecureHeaders
46
46
  @configurations[name]
47
47
  end
48
48
 
49
+ def named_appends(name)
50
+ @appends ||= {}
51
+ @appends[name]
52
+ end
53
+
54
+ def named_append(name, target = nil, &block)
55
+ @appends ||= {}
56
+ raise "Provide a configuration block" unless block_given?
57
+ @appends[name] = block
58
+ end
59
+
49
60
  private
50
61
 
51
62
  # Private: add a valid configuration to the global set of named configs.
@@ -74,7 +85,6 @@ module SecureHeaders
74
85
  ALL_HEADER_CLASSES.each do |klass|
75
86
  config.send("#{klass::CONFIG_KEY}=", OPT_OUT)
76
87
  end
77
- config.dynamic_csp = OPT_OUT
78
88
  end
79
89
 
80
90
  add_configuration(NOOP_CONFIGURATION, noop_config)
@@ -83,6 +93,7 @@ module SecureHeaders
83
93
  # Public: perform a basic deep dup. The shallow copy provided by dup/clone
84
94
  # can lead to modifying parent objects.
85
95
  def deep_copy(config)
96
+ return unless config
86
97
  config.each_with_object({}) do |(key, value), hash|
87
98
  hash[key] = if value.is_a?(Array)
88
99
  value.dup
@@ -103,13 +114,11 @@ module SecureHeaders
103
114
  end
104
115
  end
105
116
 
106
- attr_accessor :dynamic_csp
107
-
108
117
  attr_writer :hsts, :x_frame_options, :x_content_type_options,
109
118
  :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
110
- :referrer_policy
119
+ :referrer_policy, :clear_site_data
111
120
 
112
- attr_reader :cached_headers, :csp, :cookies, :hpkp, :hpkp_report_host
121
+ attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
113
122
 
114
123
  HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
115
124
  if File.exists?(HASH_CONFIG_FILE)
@@ -121,7 +130,8 @@ module SecureHeaders
121
130
  def initialize(&block)
122
131
  self.hpkp = OPT_OUT
123
132
  self.referrer_policy = OPT_OUT
124
- self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG)
133
+ self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
134
+ self.csp_report_only = OPT_OUT
125
135
  instance_eval &block if block_given?
126
136
  end
127
137
 
@@ -130,9 +140,9 @@ module SecureHeaders
130
140
  # Returns a deep-dup'd copy of this configuration.
131
141
  def dup
132
142
  copy = self.class.new
133
- copy.cookies = @cookies
134
- copy.csp = self.class.send(:deep_copy_if_hash, @csp)
135
- 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
136
146
  copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
137
147
  copy.x_content_type_options = @x_content_type_options
138
148
  copy.hsts = @hsts
@@ -140,6 +150,7 @@ module SecureHeaders
140
150
  copy.x_xss_protection = @x_xss_protection
141
151
  copy.x_download_options = @x_download_options
142
152
  copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies
153
+ copy.clear_site_data = @clear_site_data
143
154
  copy.referrer_policy = @referrer_policy
144
155
  copy.hpkp = @hpkp
145
156
  copy.hpkp_report_host = @hpkp_report_host
@@ -148,9 +159,6 @@ module SecureHeaders
148
159
 
149
160
  def opt_out(header)
150
161
  send("#{header}=", OPT_OUT)
151
- if header == CSP::CONFIG_KEY
152
- dynamic_csp = OPT_OUT
153
- end
154
162
  self.cached_headers.delete(header)
155
163
  end
156
164
 
@@ -159,20 +167,6 @@ module SecureHeaders
159
167
  self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
160
168
  end
161
169
 
162
- # Public: generated cached headers for a specific user agent.
163
- def rebuild_csp_header_cache!(user_agent)
164
- self.cached_headers[CSP::CONFIG_KEY] = {}
165
- unless current_csp == OPT_OUT
166
- user_agent = UserAgent.parse(user_agent)
167
- variation = CSP.ua_to_variation(user_agent)
168
- self.cached_headers[CSP::CONFIG_KEY][variation] = CSP.make_header(current_csp, user_agent)
169
- end
170
- end
171
-
172
- def current_csp
173
- @dynamic_csp || @csp
174
- end
175
-
176
170
  # Public: validates all configurations values.
177
171
  #
178
172
  # Raises various configuration errors if any invalid config is detected.
@@ -181,12 +175,14 @@ module SecureHeaders
181
175
  def validate_config!
182
176
  StrictTransportSecurity.validate_config!(@hsts)
183
177
  ContentSecurityPolicy.validate_config!(@csp)
178
+ ContentSecurityPolicy.validate_config!(@csp_report_only)
184
179
  ReferrerPolicy.validate_config!(@referrer_policy)
185
180
  XFrameOptions.validate_config!(@x_frame_options)
186
181
  XContentTypeOptions.validate_config!(@x_content_type_options)
187
182
  XXssProtection.validate_config!(@x_xss_protection)
188
183
  XDownloadOptions.validate_config!(@x_download_options)
189
184
  XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
185
+ ClearSiteData.validate_config!(@clear_site_data)
190
186
  PublicKeyPins.validate_config!(@hpkp)
191
187
  Cookie.validate_config!(@cookies)
192
188
  end
@@ -196,16 +192,50 @@ module SecureHeaders
196
192
  @cookies = (@cookies || {}).merge(secure: secure_cookies)
197
193
  end
198
194
 
199
- protected
200
-
201
195
  def csp=(new_csp)
202
- if self.dynamic_csp
203
- raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= instead."
196
+ if new_csp.respond_to?(:opt_out?)
197
+ @csp = new_csp.dup
198
+ else
199
+ if new_csp[:report_only]
200
+ # Deprecated configuration implies that CSPRO should be set, CSP should not - so opt out
201
+ Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp=` was supplied a config with report_only: true. Use #csp_report_only="
202
+ @csp = OPT_OUT
203
+ self.csp_report_only = new_csp
204
+ else
205
+ @csp = ContentSecurityPolicyConfig.new(new_csp)
206
+ end
207
+ end
208
+ end
209
+
210
+ # Configures the Content-Security-Policy-Report-Only header. `new_csp` cannot
211
+ # contain `report_only: false` or an error will be raised.
212
+ #
213
+ # NOTE: if csp has not been configured/has the default value when
214
+ # configuring csp_report_only, the code will assume you mean to only use
215
+ # report-only mode and you will be opted-out of enforce mode.
216
+ def csp_report_only=(new_csp)
217
+ @csp_report_only = begin
218
+ if new_csp.is_a?(ContentSecurityPolicyConfig)
219
+ new_csp.make_report_only
220
+ elsif new_csp.respond_to?(:opt_out?)
221
+ new_csp.dup
222
+ else
223
+ if new_csp[:report_only] == false # nil is a valid value on which we do not want to raise
224
+ raise ContentSecurityPolicyConfigError, "`#csp_report_only=` was supplied a config with report_only: false. Use #csp="
225
+ else
226
+ ContentSecurityPolicyReportOnlyConfig.new(new_csp)
227
+ end
228
+ end
204
229
  end
205
230
 
206
- @csp = self.class.send(:deep_copy_if_hash, new_csp)
231
+ if !@csp_report_only.opt_out? && @csp.to_h == ContentSecurityPolicyConfig::DEFAULT
232
+ 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="
233
+ @csp = OPT_OUT
234
+ end
207
235
  end
208
236
 
237
+ protected
238
+
209
239
  def cookies=(cookies)
210
240
  @cookies = cookies
211
241
  end
@@ -258,12 +288,16 @@ module SecureHeaders
258
288
  #
259
289
  # Returns nothing
260
290
  def generate_csp_headers(headers)
261
- unless @csp == OPT_OUT
262
- headers[CSP::CONFIG_KEY] = {}
263
- csp_config = self.current_csp
264
- CSP::VARIATIONS.each do |name, _|
265
- csp = CSP.make_header(csp_config, UserAgent.parse(name))
266
- headers[CSP::CONFIG_KEY][name] = csp.freeze
291
+ generate_csp_headers_for_config(headers, ContentSecurityPolicyConfig::CONFIG_KEY, self.csp)
292
+ generate_csp_headers_for_config(headers, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY, self.csp_report_only)
293
+ end
294
+
295
+ def generate_csp_headers_for_config(headers, header_key, csp_config)
296
+ unless csp_config.opt_out?
297
+ headers[header_key] = {}
298
+ ContentSecurityPolicy::VARIATIONS.each do |name, _|
299
+ csp = ContentSecurityPolicy.make_header(csp_config, UserAgent.parse(name))
300
+ headers[header_key][name] = csp.freeze
267
301
  end
268
302
  end
269
303
  end
@@ -0,0 +1,51 @@
1
+ module SecureHeaders
2
+ class ClearSiteDataConfigError < StandardError; end
3
+ class ClearSiteData
4
+ HEADER_NAME = "Clear-Site-Data".freeze
5
+ TYPES = "types".freeze
6
+
7
+ # Valid `types`
8
+ CACHE = "cache".freeze
9
+ COOKIES = "cookies".freeze
10
+ STORAGE = "storage".freeze
11
+ EXECTION_CONTEXTS = "executionContexts".freeze
12
+ ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECTION_CONTEXTS]
13
+
14
+ CONFIG_KEY = :clear_site_data
15
+
16
+ class << self
17
+ # Public: make an Clear-Site-Data header name, value pair
18
+ #
19
+ # Returns nil if not configured, returns header name and value if configured.
20
+ def make_header(config=nil)
21
+ case config
22
+ when nil, OPT_OUT, []
23
+ # noop
24
+ when Array
25
+ [HEADER_NAME, JSON.dump(TYPES => config)]
26
+ when true
27
+ [HEADER_NAME, JSON.dump(TYPES => ALL_TYPES)]
28
+ end
29
+ end
30
+
31
+ def validate_config!(config)
32
+ case config
33
+ when nil, OPT_OUT, true
34
+ # valid
35
+ when Array
36
+ unless config.all? { |t| t.is_a?(String) }
37
+ raise ClearSiteDataConfigError.new("types must be Strings")
38
+ end
39
+
40
+ begin
41
+ JSON.dump(config)
42
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
43
+ raise ClearSiteDataConfigError.new("types must serializable by JSON")
44
+ end
45
+ else
46
+ raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end