secure_headers 6.3.4 → 7.3.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -2
  3. data/Gemfile +2 -0
  4. data/README.md +81 -15
  5. data/lib/secure_headers/configuration.rb +64 -13
  6. data/lib/secure_headers/headers/clear_site_data.rb +31 -33
  7. data/lib/secure_headers/headers/content_security_policy.rb +80 -46
  8. data/lib/secure_headers/headers/content_security_policy_config.rb +17 -58
  9. data/lib/secure_headers/headers/cookie.rb +4 -6
  10. data/lib/secure_headers/headers/expect_certificate_transparency.rb +20 -22
  11. data/lib/secure_headers/headers/policy_management.rb +66 -18
  12. data/lib/secure_headers/headers/referrer_policy.rb +20 -22
  13. data/lib/secure_headers/headers/reporting_endpoints.rb +54 -0
  14. data/lib/secure_headers/headers/strict_transport_security.rb +13 -15
  15. data/lib/secure_headers/headers/x_content_type_options.rb +14 -16
  16. data/lib/secure_headers/headers/x_download_options.rb +14 -16
  17. data/lib/secure_headers/headers/x_frame_options.rb +14 -16
  18. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +14 -16
  19. data/lib/secure_headers/headers/x_xss_protection.rb +14 -16
  20. data/lib/secure_headers/middleware.rb +11 -7
  21. data/lib/secure_headers/railtie.rb +11 -8
  22. data/lib/secure_headers/task_helper.rb +65 -0
  23. data/lib/secure_headers/version.rb +1 -1
  24. data/lib/secure_headers/view_helper.rb +7 -6
  25. data/lib/secure_headers.rb +26 -2
  26. data/lib/tasks/tasks.rake +4 -53
  27. data/secure_headers.gemspec +19 -7
  28. metadata +38 -70
  29. data/.github/ISSUE_TEMPLATE.md +0 -41
  30. data/.github/PULL_REQUEST_TEMPLATE.md +0 -20
  31. data/.github/workflows/build.yml +0 -24
  32. data/.github/workflows/github-release.yml +0 -28
  33. data/.gitignore +0 -13
  34. data/.rspec +0 -3
  35. data/.rubocop.yml +0 -4
  36. data/.ruby-gemset +0 -1
  37. data/.ruby-version +0 -1
  38. data/CODE_OF_CONDUCT.md +0 -46
  39. data/CONTRIBUTING.md +0 -41
  40. data/Guardfile +0 -13
  41. data/Rakefile +0 -32
  42. data/docs/cookies.md +0 -65
  43. data/docs/hashes.md +0 -64
  44. data/docs/named_overrides_and_appends.md +0 -104
  45. data/docs/per_action_configuration.md +0 -139
  46. data/docs/sinatra.md +0 -25
  47. data/docs/upgrading-to-3-0.md +0 -42
  48. data/docs/upgrading-to-4-0.md +0 -35
  49. data/docs/upgrading-to-5-0.md +0 -15
  50. data/docs/upgrading-to-6-0.md +0 -50
  51. data/spec/lib/secure_headers/configuration_spec.rb +0 -121
  52. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +0 -87
  53. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +0 -190
  54. data/spec/lib/secure_headers/headers/cookie_spec.rb +0 -179
  55. data/spec/lib/secure_headers/headers/expect_certificate_transparency_spec.rb +0 -42
  56. data/spec/lib/secure_headers/headers/policy_management_spec.rb +0 -264
  57. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +0 -91
  58. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +0 -33
  59. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +0 -31
  60. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +0 -29
  61. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +0 -36
  62. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +0 -48
  63. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +0 -47
  64. data/spec/lib/secure_headers/middleware_spec.rb +0 -117
  65. data/spec/lib/secure_headers/view_helpers_spec.rb +0 -191
  66. data/spec/lib/secure_headers_spec.rb +0 -516
  67. data/spec/spec_helper.rb +0 -64
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3953b8d0c4ce0a01012d4d6475ad94c60ce4b06b9d93994ef14cc2474ced6177
4
- data.tar.gz: 3aa96804cacf26d4a275b19e870755313a3afd055b598014749d0338e4f0f4af
3
+ metadata.gz: a859861ca3a93245cbfa9c421b209c44800ff3b5bd92c26591bdd99dde7efd30
4
+ data.tar.gz: 8576b10d0855d453cf42ba9335cbe8deeb8f82258b04ada1c2f2ab01a04ae373
5
5
  SHA512:
6
- metadata.gz: b04fd60ff28519273f29d335b81b44ebfe938d4e97d82d31b69d8596dc84e38c812573248e7b70bb142fecebab357c7f34f9f10934edaf354e3174915220580f
7
- data.tar.gz: 10748a3ff12365fffe42828fe324e0bfbb3f146635b095a9e8a13b5a57b5bdfe17a5463df3b5009d8591dbc88494c45036a12dd061fc6b8e921c4c99a0d523ef
6
+ metadata.gz: 95d65823683591d7c3a4c26bc99b6849f16b4caecbd9c5c1fd73ffe22e75311cf2124d6ce1fd25670dd4c11120ce01dbb46cd003c15387f3bfebfe4592c3542f
7
+ data.tar.gz: ef35df794270845ab6807c50a9e37e1832c3e4e5293098038f2caf6b3635aae024a907df99ff9e5e4e0580fb7a7995d0d63f6d9b9e0452aca22f024b7be297e9
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 6.5.0
2
+
3
+ - CSP: Remove source expression deduplication. (@lgarron) https://github.com/github/secure_headers/pull/499
4
+
5
+ ## 6.4.0
6
+
7
+ - CSP: Add support for trusted-types, require-trusted-types-for directive (@JackMc): https://github.com/github/secure_headers/pull/486
8
+
1
9
  ## 6.3.4
2
10
 
3
11
  - CSP: Do not deduplicate alternate schema source expressions (@keithamus): https://github.com/github/secure_headers/pull/478
@@ -62,7 +70,7 @@ NOTE: this version is a breaking change due to the removal of HPKP. Remove the H
62
70
 
63
71
  ## 5.0.0
64
72
 
65
- Well this is a little embarassing. 4.0 was supposed to set the secure/httponly/samesite=lax attributes on cookies by default but it didn't. Now it does. - See the [upgrading to 5.0](docs/upgrading-to-5-0.md) guide.
73
+ Well this is a little embarrassing. 4.0 was supposed to set the secure/httponly/samesite=lax attributes on cookies by default but it didn't. Now it does. - See the [upgrading to 5.0](docs/upgrading-to-5-0.md) guide.
66
74
 
67
75
  ## 4.0.1
68
76
 
@@ -186,7 +194,7 @@ end
186
194
 
187
195
  ## 3.4.0 the frame-src/child-src transition for Firefox.
188
196
 
189
- Handle the `child-src`/`frame-src` transition semi-intelligently across versions. I think the code best descibes the behavior here:
197
+ Handle the `child-src`/`frame-src` transition semi-intelligently across versions. I think the code best describes the behavior here:
190
198
 
191
199
  ```ruby
192
200
  if supported_directives.include?(:child_src)
data/Gemfile CHANGED
@@ -3,6 +3,8 @@ source "https://rubygems.org"
3
3
 
4
4
  gemspec
5
5
 
6
+ gem "benchmark-ips"
7
+
6
8
  group :test do
7
9
  gem "coveralls"
8
10
  gem "json"
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Secure Headers ![Build + Test](https://github.com/github/secure_headers/workflows/Build%20+%20Test/badge.svg?branch=main)
1
+ # Secure Headers [![Build + Test](https://github.com/github/secure_headers/actions/workflows/build.yml/badge.svg)](https://github.com/github/secure_headers/actions/workflows/build.yml)
2
2
 
3
- **main branch represents 6.x line**. See the [upgrading to 4.x doc](docs/upgrading-to-4-0.md), [upgrading to 5.x doc](docs/upgrading-to-5-0.md), or [upgrading to 6.x doc](docs/upgrading-to-6-0.md) for instructions on how to upgrade. Bug fixes should go in the 5.x branch for now.
3
+ **main branch represents 7.x line**. See the [upgrading to 4.x doc](docs/upgrading-to-4-0.md), [upgrading to 5.x doc](docs/upgrading-to-5-0.md), [upgrading to 6.x doc](docs/upgrading-to-6-0.md) or [upgrading to 7.x doc](docs/upgrading-to-7-0.md) for instructions on how to upgrade. Bug fixes should go in the `6.x` branch for now.
4
4
 
5
5
  The gem will automatically apply several headers that are related to security. This includes:
6
6
  - Content Security Policy (CSP) - Helps detect/prevent XSS, mixed-content, and other classes of attack. [CSP 2 Specification](https://www.w3.org/TR/CSP2/)
@@ -11,11 +11,11 @@ The gem will automatically apply several headers that are related to security.
11
11
  - X-Frame-Options (XFO) - Prevents your content from being framed and potentially clickjacked. [X-Frame-Options Specification](https://tools.ietf.org/html/rfc7034)
12
12
  - X-XSS-Protection - [Cross site scripting heuristic filter for IE/Chrome](https://msdn.microsoft.com/en-us/library/dd565647\(v=vs.85\).aspx)
13
13
  - X-Content-Type-Options - [Prevent content type sniffing](https://msdn.microsoft.com/library/gg622941\(v=vs.85\).aspx)
14
- - X-Download-Options - [Prevent file downloads opening](https://msdn.microsoft.com/library/jj542450(v=vs.85).aspx)
15
- - X-Permitted-Cross-Domain-Policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html)
16
- - Referrer-Policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/)
17
- - Expect-CT - Only use certificates that are present in the certificate transparency logs. [Expect-CT draft specification](https://datatracker.ietf.org/doc/draft-stark-expect-ct/).
18
- - Clear-Site-Data - Clearing browser data for origin. [Clear-Site-Data specification](https://w3c.github.io/webappsec-clear-site-data/).
14
+ - x-download-options - [Prevent file downloads opening](https://msdn.microsoft.com/library/jj542450(v=vs.85).aspx)
15
+ - x-permitted-cross-domain-policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html)
16
+ - referrer-policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/)
17
+ - expect-ct - Only use certificates that are present in the certificate transparency logs. [expect-ct draft specification](https://datatracker.ietf.org/doc/draft-stark-expect-ct/).
18
+ - clear-site-data - Clearing browser data for origin. [clear-site-data specification](https://w3c.github.io/webappsec-clear-site-data/).
19
19
 
20
20
  It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes. This is on default but can be turned off by using `config.cookies = SecureHeaders::OPT_OUT`.
21
21
 
@@ -62,7 +62,6 @@ SecureHeaders::Configuration.default do |config|
62
62
  # directive values: these values will directly translate into source directives
63
63
  default_src: %w('none'),
64
64
  base_uri: %w('self'),
65
- block_all_mixed_content: true, # see https://www.w3.org/TR/mixed-content/
66
65
  child_src: %w('self'), # if child-src isn't supported, the value for frame-src will be set.
67
66
  connect_src: %w(wss:),
68
67
  font_src: %w('self' data:),
@@ -89,21 +88,65 @@ SecureHeaders::Configuration.default do |config|
89
88
  img_src: %w(somewhereelse.com),
90
89
  report_uri: %w(https://report-uri.io/example-csp-report-only)
91
90
  })
91
+
92
+ # Optional: Use the modern report-to directive (with Reporting-Endpoints header)
93
+ config.csp = config.csp.merge({
94
+ report_to: "csp-endpoint"
95
+ })
96
+
97
+ # When using report-to, configure the reporting endpoints header
98
+ config.reporting_endpoints = {
99
+ "csp-endpoint": "https://report-uri.io/example-csp",
100
+ "csp-report-only": "https://report-uri.io/example-csp-report-only"
101
+ }
92
102
  end
93
103
  ```
94
104
 
105
+ ### CSP Reporting
106
+
107
+ SecureHeaders supports both the legacy `report-uri` and the modern `report-to` directives for CSP violation reporting:
108
+
109
+ #### report-uri (Legacy)
110
+ The `report-uri` directive sends violations to a URL endpoint. It's widely supported but limited to POST requests with JSON payloads.
111
+
112
+ ```ruby
113
+ config.csp = {
114
+ default_src: %w('self'),
115
+ report_uri: %w(https://example.com/csp-report)
116
+ }
117
+ ```
118
+
119
+ #### report-to (Modern)
120
+ The `report-to` directive specifies a named reporting endpoint defined in the `Reporting-Endpoints` header. This enables more flexible reporting through the HTTP Reporting API standard.
121
+
122
+ ```ruby
123
+ config.csp = {
124
+ default_src: %w('self'),
125
+ report_to: "csp-endpoint"
126
+ }
127
+
128
+ config.reporting_endpoints = {
129
+ "csp-endpoint": "https://example.com/reports"
130
+ }
131
+ ```
132
+
133
+ **Recommendation:** Use both `report-uri` and `report-to` for maximum compatibility while transitioning to the modern approach.
134
+
135
+ ### Deprecated Configuration Values
136
+ * `block_all_mixed_content` - this value is deprecated in favor of `upgrade_insecure_requests`. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/block-all-mixed-content for more information.
137
+
95
138
  ## Default values
96
139
 
97
140
  All headers except for PublicKeyPins and ClearSiteData have a default value. The default set of headers is:
98
141
 
99
142
  ```
100
- 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'
101
- Strict-Transport-Security: max-age=631138519
102
- X-Content-Type-Options: nosniff
103
- X-Download-Options: noopen
104
- X-Frame-Options: sameorigin
105
- X-Permitted-Cross-Domain-Policies: none
106
- X-Xss-Protection: 1; mode=block
143
+ 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'
144
+ strict-transport-security: max-age=631138519
145
+ x-content-type-options: nosniff
146
+ x-download-options: noopen
147
+ x-frame-options: sameorigin
148
+ x-permitted-cross-domain-policies: none
149
+ x-xss-protection: 0
107
150
  ```
108
151
 
109
152
  ## API configurations
@@ -123,6 +166,29 @@ end
123
166
 
124
167
  However, I would consider these headers anyways depending on your load and bandwidth requirements.
125
168
 
169
+ ## Disabling secure_headers
170
+
171
+ If you want to disable `secure_headers` entirely (e.g., for specific environments or deployment scenarios), you can use `Configuration.disable!`:
172
+
173
+ ```ruby
174
+ if ENV["ENABLE_STRICT_HEADERS"]
175
+ SecureHeaders::Configuration.default do |config|
176
+ # your configuration here
177
+ end
178
+ else
179
+ SecureHeaders::Configuration.disable!
180
+ end
181
+ ```
182
+
183
+ **Important**: This configuration must be set during application startup (e.g., in an initializer). Once you call either `Configuration.default` or `Configuration.disable!`, the choice cannot be changed at runtime. Attempting to call `disable!` after `default` (or vice versa) will raise an `AlreadyConfiguredError`.
184
+
185
+ When disabled, no security headers will be set by the gem. This is useful when:
186
+ - You're gradually rolling out secure_headers across different customers or deployments
187
+ - You need to migrate existing custom headers to secure_headers
188
+ - You want environment-specific control over security headers
189
+
190
+ Note: When `disable!` is used, you don't need to configure a default configuration. The gem will not raise a `NotYetConfiguredError`.
191
+
126
192
  ## Acknowledgements
127
193
 
128
194
  This project originated within the Security team at Twitter. An archived fork from the point of transition is here: https://github.com/twitter-archive/secure_headers.
@@ -9,23 +9,53 @@ module SecureHeaders
9
9
  class NotYetConfiguredError < StandardError; end
10
10
  class IllegalPolicyModificationError < StandardError; end
11
11
  class << self
12
+ # Public: Disable secure_headers entirely. When disabled, no headers will be set.
13
+ #
14
+ # Note: This must be called before Configuration.default. Calling it after
15
+ # Configuration.default has been set will raise an AlreadyConfiguredError.
16
+ #
17
+ # Returns nothing
18
+ # Raises AlreadyConfiguredError if Configuration.default has already been called
19
+ def disable!
20
+ if defined?(@default_config)
21
+ raise AlreadyConfiguredError, "Configuration already set, cannot disable"
22
+ end
23
+
24
+ @disabled = true
25
+ @noop_config = create_noop_config.freeze
26
+
27
+ # Ensure the built-in NOOP override is available even if `default` has never been called
28
+ @overrides ||= {}
29
+ unless @overrides.key?(NOOP_OVERRIDE)
30
+ @overrides[NOOP_OVERRIDE] = method(:create_noop_config_block)
31
+ end
32
+ end
33
+
34
+ # Public: Check if secure_headers is disabled
35
+ #
36
+ # Returns boolean
37
+ def disabled?
38
+ defined?(@disabled) && @disabled
39
+ end
40
+
12
41
  # Public: Set the global default configuration.
13
42
  #
14
43
  # Optionally supply a block to override the defaults set by this library.
15
44
  #
16
45
  # Returns the newly created config.
46
+ # Raises AlreadyConfiguredError if Configuration.disable! has already been called
17
47
  def default(&block)
48
+ if disabled?
49
+ raise AlreadyConfiguredError, "Configuration has been disabled, cannot set default"
50
+ end
51
+
18
52
  if defined?(@default_config)
19
53
  raise AlreadyConfiguredError, "Policy already configured"
20
54
  end
21
55
 
22
56
  # Define a built-in override that clears all configuration options and
23
57
  # results in no security headers being set.
24
- override(NOOP_OVERRIDE) do |config|
25
- CONFIG_ATTRIBUTES.each do |attr|
26
- config.instance_variable_set("@#{attr}", OPT_OUT)
27
- end
28
- end
58
+ override(NOOP_OVERRIDE, &method(:create_noop_config_block))
29
59
 
30
60
  new_config = new(&block).freeze
31
61
  new_config.validate_config!
@@ -35,7 +65,7 @@ module SecureHeaders
35
65
 
36
66
  # Public: create a named configuration that overrides the default config.
37
67
  #
38
- # name - use an idenfier for the override config.
68
+ # name - use an identifier for the override config.
39
69
  # base - override another existing config, or override the default config
40
70
  # if no value is supplied.
41
71
  #
@@ -83,13 +113,17 @@ module SecureHeaders
83
113
  # can lead to modifying parent objects.
84
114
  def deep_copy(config)
85
115
  return unless config
86
- config.each_with_object({}) do |(key, value), hash|
87
- hash[key] = if value.is_a?(Array)
88
- value.dup
89
- else
90
- value
91
- end
116
+ result = {}
117
+ config.each_pair do |key, value|
118
+ result[key] =
119
+ case value
120
+ when Array
121
+ value.dup
122
+ else
123
+ value
124
+ end
92
125
  end
126
+ result
93
127
  end
94
128
 
95
129
  # Private: Returns the internal default configuration. This should only
@@ -97,6 +131,7 @@ module SecureHeaders
97
131
  # of ensuring that the default config is never mutated and is dup(ed)
98
132
  # before it is used in a request.
99
133
  def default_config
134
+ return @noop_config if disabled?
100
135
  unless defined?(@default_config)
101
136
  raise NotYetConfiguredError, "Default policy not yet configured"
102
137
  end
@@ -112,6 +147,19 @@ module SecureHeaders
112
147
  value
113
148
  end
114
149
  end
150
+
151
+ # Private: Creates a NOOP configuration that opts out of all headers
152
+ def create_noop_config
153
+ new(&method(:create_noop_config_block))
154
+ end
155
+
156
+ # Private: Block for creating NOOP configuration
157
+ # Used by both create_noop_config and the NOOP_OVERRIDE mechanism
158
+ def create_noop_config_block(config)
159
+ CONFIG_ATTRIBUTES.each do |attr|
160
+ config.instance_variable_set("@#{attr}", OPT_OUT)
161
+ end
162
+ end
115
163
  end
116
164
 
117
165
  CONFIG_ATTRIBUTES_TO_HEADER_CLASSES = {
@@ -127,6 +175,7 @@ module SecureHeaders
127
175
  csp: ContentSecurityPolicy,
128
176
  csp_report_only: ContentSecurityPolicy,
129
177
  cookies: Cookie,
178
+ reporting_endpoints: ReportingEndpoints,
130
179
  }.freeze
131
180
 
132
181
  CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze
@@ -163,6 +212,7 @@ module SecureHeaders
163
212
  @x_permitted_cross_domain_policies = nil
164
213
  @x_xss_protection = nil
165
214
  @expect_certificate_transparency = nil
215
+ @reporting_endpoints = nil
166
216
 
167
217
  self.referrer_policy = OPT_OUT
168
218
  self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
@@ -188,6 +238,7 @@ module SecureHeaders
188
238
  copy.clear_site_data = @clear_site_data
189
239
  copy.expect_certificate_transparency = @expect_certificate_transparency
190
240
  copy.referrer_policy = @referrer_policy
241
+ copy.reporting_endpoints = self.class.send(:deep_copy_if_hash, @reporting_endpoints)
191
242
  copy
192
243
  end
193
244
 
@@ -252,7 +303,7 @@ module SecureHeaders
252
303
  end
253
304
  end
254
305
 
255
- # Configures the Content-Security-Policy-Report-Only header. `new_csp` cannot
306
+ # Configures the content-security-policy-report-only header. `new_csp` cannot
256
307
  # contain `report_only: false` or an error will be raised.
257
308
  #
258
309
  # NOTE: if csp has not been configured/has the default value when
@@ -2,7 +2,7 @@
2
2
  module SecureHeaders
3
3
  class ClearSiteDataConfigError < StandardError; end
4
4
  class ClearSiteData
5
- HEADER_NAME = "Clear-Site-Data".freeze
5
+ HEADER_NAME = "clear-site-data".freeze
6
6
 
7
7
  # Valid `types`
8
8
  CACHE = "cache".freeze
@@ -11,43 +11,41 @@ module SecureHeaders
11
11
  EXECUTION_CONTEXTS = "executionContexts".freeze
12
12
  ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
13
13
 
14
- class << self
15
- # Public: make an Clear-Site-Data header name, value pair
16
- #
17
- # Returns nil if not configured, returns header name and value if configured.
18
- def make_header(config = nil, user_agent = nil)
19
- case config
20
- when nil, OPT_OUT, []
21
- # noop
22
- when Array
23
- [HEADER_NAME, make_header_value(config)]
24
- when true
25
- [HEADER_NAME, make_header_value(ALL_TYPES)]
26
- end
14
+ # Public: make an clear-site-data header name, value pair
15
+ #
16
+ # Returns nil if not configured, returns header name and value if configured.
17
+ def self.make_header(config = nil, user_agent = nil)
18
+ case config
19
+ when nil, OPT_OUT, []
20
+ # noop
21
+ when Array
22
+ [HEADER_NAME, make_header_value(config)]
23
+ when true
24
+ [HEADER_NAME, make_header_value(ALL_TYPES)]
27
25
  end
26
+ end
28
27
 
29
- def validate_config!(config)
30
- case config
31
- when nil, OPT_OUT, true
32
- # valid
33
- when Array
34
- unless config.all? { |t| t.is_a?(String) }
35
- raise ClearSiteDataConfigError.new("types must be Strings")
36
- end
37
- else
38
- raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
28
+ def self.validate_config!(config)
29
+ case config
30
+ when nil, OPT_OUT, true
31
+ # valid
32
+ when Array
33
+ unless config.all? { |t| t.is_a?(String) }
34
+ raise ClearSiteDataConfigError.new("types must be Strings")
39
35
  end
36
+ else
37
+ raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
40
38
  end
39
+ end
41
40
 
42
- # Public: Transform a Clear-Site-Data config (an Array of Strings) into a
43
- # String that can be used as the value for the Clear-Site-Data header.
44
- #
45
- # types - An Array of String of types of data to clear.
46
- #
47
- # Returns a String of quoted values that are comma separated.
48
- def make_header_value(types)
49
- types.map { |t| %("#{t}") }.join(", ")
50
- end
41
+ # Public: Transform a clear-site-data config (an Array of Strings) into a
42
+ # String that can be used as the value for the clear-site-data header.
43
+ #
44
+ # types - An Array of String of types of data to clear.
45
+ #
46
+ # Returns a String of quoted values that are comma separated.
47
+ def self.make_header_value(types)
48
+ types.map { |t| %("#{t}") }.join(", ")
51
49
  end
52
50
  end
53
51
  end
@@ -7,26 +7,27 @@ module SecureHeaders
7
7
  include PolicyManagement
8
8
 
9
9
  def initialize(config = nil)
10
- @config = if config.is_a?(Hash)
11
- if config[:report_only]
12
- ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
10
+ @config =
11
+ if config.is_a?(Hash)
12
+ if config[:report_only]
13
+ ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
14
+ else
15
+ ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
16
+ end
17
+ elsif config.nil?
18
+ ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
13
19
  else
14
- ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
20
+ config
15
21
  end
16
- elsif config.nil?
17
- ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
18
- else
19
- config
20
- end
21
22
 
22
- @preserve_schemes = @config.preserve_schemes
23
- @script_nonce = @config.script_nonce
24
- @style_nonce = @config.style_nonce
23
+ @preserve_schemes = @config[:preserve_schemes]
24
+ @script_nonce = @config[:script_nonce]
25
+ @style_nonce = @config[:style_nonce]
25
26
  end
26
27
 
27
28
  ##
28
- # Returns the name to use for the header. Either "Content-Security-Policy" or
29
- # "Content-Security-Policy-Report-Only"
29
+ # Returns the name to use for the header. Either "content-security-policy" or
30
+ # "content-security-policy-report-only"
30
31
  def name
31
32
  @config.class.const_get(:HEADER_NAME)
32
33
  end
@@ -34,11 +35,12 @@ module SecureHeaders
34
35
  ##
35
36
  # Return the value of the CSP header
36
37
  def value
37
- @value ||= if @config
38
- build_value
39
- else
40
- DEFAULT_VALUE
41
- end
38
+ @value ||=
39
+ if @config
40
+ build_value
41
+ else
42
+ DEFAULT_VALUE
43
+ end
42
44
  end
43
45
 
44
46
  private
@@ -51,7 +53,9 @@ module SecureHeaders
51
53
  def build_value
52
54
  directives.map do |directive_name|
53
55
  case DIRECTIVE_VALUE_TYPES[directive_name]
54
- when :source_list, :require_sri_for_list # require_sri is a simple set of strings that don't need to deal with symbol casing
56
+ when :source_list,
57
+ :require_sri_for_list, # require_sri is a simple set of strings that don't need to deal with symbol casing
58
+ :require_trusted_types_for_list
55
59
  build_source_list_directive(directive_name)
56
60
  when :boolean
57
61
  symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
@@ -59,6 +63,8 @@ module SecureHeaders
59
63
  build_sandbox_list_directive(directive_name)
60
64
  when :media_type_list
61
65
  build_media_type_list_directive(directive_name)
66
+ when :report_to_endpoint
67
+ build_report_to_directive(directive_name)
62
68
  end
63
69
  end.compact.join("; ")
64
70
  end
@@ -75,13 +81,13 @@ module SecureHeaders
75
81
  end
76
82
 
77
83
  # A maximally strict sandbox policy is just the `sandbox` directive,
78
- # whith no configuraiton values.
84
+ # with no configuration values.
79
85
  if max_strict_policy
80
86
  symbol_to_hyphen_case(directive)
81
87
  elsif sandbox_list && sandbox_list.any?
82
88
  [
83
89
  symbol_to_hyphen_case(directive),
84
- sandbox_list.uniq
90
+ scrub_directive_value(directive, sandbox_list.uniq.join(" "))
85
91
  ].join(" ")
86
92
  end
87
93
  end
@@ -91,11 +97,40 @@ module SecureHeaders
91
97
  if media_type_list && media_type_list.any?
92
98
  [
93
99
  symbol_to_hyphen_case(directive),
94
- media_type_list.uniq
100
+ scrub_directive_value(directive, media_type_list.uniq.join(" "))
95
101
  ].join(" ")
96
102
  end
97
103
  end
98
104
 
105
+ def build_report_to_directive(directive)
106
+ return unless endpoint_name = @config.directive_value(directive)
107
+ if endpoint_name && endpoint_name.is_a?(String) && !endpoint_name.empty?
108
+ [symbol_to_hyphen_case(directive), scrub_directive_value(directive, endpoint_name)].join(" ")
109
+ end
110
+ end
111
+
112
+ # Bytes that would let a caller-controlled value break out of its
113
+ # directive and inject sibling CSP directives. CR/LF are included
114
+ # so naive downstreams that split on bare \r can't be used to
115
+ # smuggle directives either.
116
+ DIRECTIVE_INJECTION_REGEX = /[\n\r;]/.freeze
117
+
118
+ # Private: scrubs caller-controlled bytes that would let a value
119
+ # break out of its CSP directive (CR, LF, semicolon). Shared across
120
+ # every directive builder so sandbox / plugin-types / report-to /
121
+ # source-list all reject the same byte set with the same warn UX.
122
+ # Emits a single Kernel.warn per directive even when multiple
123
+ # offending bytes are present.
124
+ def scrub_directive_value(directive, value)
125
+ str = value.to_s
126
+ if str =~ DIRECTIVE_INJECTION_REGEX
127
+ Kernel.warn("#{directive} contains a #{$~[0].inspect} in #{str.inspect} which will raise an error in future versions. It has been replaced with a blank space.")
128
+ str.gsub(DIRECTIVE_INJECTION_REGEX, " ")
129
+ else
130
+ str
131
+ end
132
+ end
133
+
99
134
  # Private: builds a string that represents one directive in a minified form.
100
135
  #
101
136
  # directive_name - a symbol representing the various ALL_DIRECTIVES
@@ -105,18 +140,13 @@ module SecureHeaders
105
140
  source_list = @config.directive_value(directive)
106
141
  if source_list != OPT_OUT && source_list && source_list.any?
107
142
  minified_source_list = minify_source_list(directive, source_list).join(" ")
108
-
109
- if minified_source_list =~ /(\n|;)/
110
- Kernel.warn("#{directive} contains a #{$1} in #{minified_source_list.inspect} which will raise an error in future versions. It has been replaced with a blank space.")
111
- end
112
-
113
- escaped_source_list = minified_source_list.gsub(/[\n;]/, " ")
143
+ escaped_source_list = scrub_directive_value(directive, minified_source_list)
114
144
  [symbol_to_hyphen_case(directive), escaped_source_list].join(" ").strip
115
145
  end
116
146
  end
117
147
 
118
148
  # If a directive contains *, all other values are omitted.
119
- # If a directive contains 'none' but has other values, 'none' is ommitted.
149
+ # If a directive contains 'none' but has other values, 'none' is omitted.
120
150
  # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
121
151
  def minify_source_list(directive, source_list)
122
152
  source_list = source_list.compact
@@ -125,11 +155,12 @@ module SecureHeaders
125
155
  else
126
156
  source_list = populate_nonces(directive, source_list)
127
157
  source_list = reject_all_values_if_none(source_list)
158
+ source_list = normalize_uri_paths(source_list)
128
159
 
129
160
  unless directive == REPORT_URI || @preserve_schemes
130
161
  source_list = strip_source_schemes(source_list)
131
162
  end
132
- dedup_source_list(source_list)
163
+ source_list.uniq
133
164
  end
134
165
  end
135
166
 
@@ -147,21 +178,23 @@ module SecureHeaders
147
178
  end
148
179
  end
149
180
 
150
- # Removes duplicates and sources that already match an existing wild card.
151
- #
152
- # e.g. *.github.com asdf.github.com becomes *.github.com
153
- def dedup_source_list(sources)
154
- sources = sources.uniq
155
- wild_sources = sources.select { |source| source =~ STAR_REGEXP }
156
-
157
- if wild_sources.any?
158
- schemes = sources.map { |source| [source, URI(source).scheme] }.to_h
159
- sources.reject do |source|
160
- !wild_sources.include?(source) &&
161
- wild_sources.any? { |pattern| schemes[pattern] == schemes[source] && File.fnmatch(pattern, source) }
181
+ def normalize_uri_paths(source_list)
182
+ source_list.map do |source|
183
+ # Normalize domains ending in a single / as without omitting the slash accomplishes the same.
184
+ # https://www.w3.org/TR/CSP3/#match-paths § 6.6.2.10 Step 2
185
+ begin
186
+ uri = URI(source)
187
+ if uri.path == "/"
188
+ next source.chomp("/")
189
+ end
190
+ rescue URI::InvalidURIError
191
+ end
192
+
193
+ if source.chomp("/").include?("/")
194
+ source
195
+ else
196
+ source.chomp("/")
162
197
  end
163
- else
164
- sources
165
198
  end
166
199
  end
167
200
 
@@ -193,11 +226,12 @@ module SecureHeaders
193
226
  end
194
227
 
195
228
  # Private: return the list of directives,
196
- # starting with default-src and ending with report-uri.
229
+ # starting with default-src and ending with reporting directives (alphabetically ordered).
197
230
  def directives
198
231
  [
199
232
  DEFAULT_SRC,
200
233
  BODY_DIRECTIVES,
234
+ REPORT_TO,
201
235
  REPORT_URI,
202
236
  ].flatten
203
237
  end