Skip to content

Commit 7f83b93

Browse files
rei-mooderilobriekeithamusdgreif
authored
7.2 release (#566)
- #551 - #555 - #477 - #485 - #568 - #570 Fixes: #541 Fixes: #514 Fixes: #450 Fixes: #348 --------- Co-authored-by: Dmytro Bihniak <dmybih@attensi.com> Co-authored-by: Aaron Pfeifer <aaron.pfeifer@gmail.com> Co-authored-by: Keith Cirkel <keithamus@users.noreply.github.com> Co-authored-by: Dusty Greif <dgreif@users.noreply.github.com> Co-authored-by: Matt Langlois <fletchto99@github.com> Co-authored-by: Ryan Ahearn <ryan.ahearn@gsa.gov> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: fletchto99 <718681+fletchto99@users.noreply.github.com>m> Co-authored-by: fletchto99 <718681+fletchto99@users.noreply.github.com> Co-authored-by: Kylie Stradley <4666485+KyFaSt@users.noreply.github.com> Co-authored-by: Kylie Stradley <kyfast@users.noreply.github.com>
1 parent b13d4b6 commit 7f83b93

19 files changed

+1168
-77
lines changed

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,50 @@ SecureHeaders::Configuration.default do |config|
8888
img_src: %w(somewhereelse.com),
8989
report_uri: %w(https://report-uri.io/example-csp-report-only)
9090
})
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+
}
91102
end
92103
```
93104

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+
94135
### Deprecated Configuration Values
95136
* `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.
96137

@@ -125,6 +166,29 @@ end
125166

126167
However, I would consider these headers anyways depending on your load and bandwidth requirements.
127168

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+
128192
## Acknowledgements
129193

130194
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.

lib/secure_headers.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
require "secure_headers/headers/referrer_policy"
1212
require "secure_headers/headers/clear_site_data"
1313
require "secure_headers/headers/expect_certificate_transparency"
14+
require "secure_headers/headers/reporting_endpoints"
1415
require "secure_headers/middleware"
1516
require "secure_headers/railtie"
1617
require "secure_headers/view_helper"
@@ -133,6 +134,7 @@ def opt_out_of_all_protection(request)
133134
# request.
134135
#
135136
# StrictTransportSecurity is not applied to http requests.
137+
# upgrade_insecure_requests is not applied to http requests.
136138
# See #config_for to determine which config is used for a given request.
137139
#
138140
# Returns a hash of header names => header values. The value
@@ -146,6 +148,11 @@ def header_hash_for(request)
146148

147149
if request.scheme != HTTPS
148150
headers.delete(StrictTransportSecurity::HEADER_NAME)
151+
152+
# Remove upgrade_insecure_requests from CSP headers for HTTP requests
153+
# as it doesn't make sense to upgrade requests when the page itself is served over HTTP
154+
remove_upgrade_insecure_requests_from_csp!(headers, config.csp)
155+
remove_upgrade_insecure_requests_from_csp!(headers, config.csp_report_only)
149156
end
150157
headers
151158
end
@@ -242,6 +249,23 @@ def content_security_policy_nonce(request, script_or_style)
242249
def override_secure_headers_request_config(request, config)
243250
request.env[SECURE_HEADERS_CONFIG] = config
244251
end
252+
253+
# Private: removes upgrade_insecure_requests directive from a CSP config
254+
# if it's present, and updates the headers hash with the modified CSP.
255+
#
256+
# headers - the headers hash to update
257+
# csp_config - the CSP config to check and potentially modify
258+
#
259+
# Returns nothing (modifies headers in place)
260+
def remove_upgrade_insecure_requests_from_csp!(headers, csp_config)
261+
return if csp_config.opt_out?
262+
return unless csp_config.directive_value(ContentSecurityPolicy::UPGRADE_INSECURE_REQUESTS)
263+
264+
modified_config = csp_config.dup
265+
modified_config.update_directive(ContentSecurityPolicy::UPGRADE_INSECURE_REQUESTS, false)
266+
header_name, value = ContentSecurityPolicy.make_header(modified_config)
267+
headers[header_name] = value if header_name && value
268+
end
245269
end
246270

247271
# These methods are mixed into controllers and delegate to the class method

lib/secure_headers/configuration.rb

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,53 @@ class AlreadyConfiguredError < StandardError; end
99
class NotYetConfiguredError < StandardError; end
1010
class IllegalPolicyModificationError < StandardError; end
1111
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+
1241
# Public: Set the global default configuration.
1342
#
1443
# Optionally supply a block to override the defaults set by this library.
1544
#
1645
# Returns the newly created config.
46+
# Raises AlreadyConfiguredError if Configuration.disable! has already been called
1747
def default(&block)
48+
if disabled?
49+
raise AlreadyConfiguredError, "Configuration has been disabled, cannot set default"
50+
end
51+
1852
if defined?(@default_config)
1953
raise AlreadyConfiguredError, "Policy already configured"
2054
end
2155

2256
# Define a built-in override that clears all configuration options and
2357
# 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))
2959

3060
new_config = new(&block).freeze
3161
new_config.validate_config!
@@ -101,6 +131,7 @@ def deep_copy(config)
101131
# of ensuring that the default config is never mutated and is dup(ed)
102132
# before it is used in a request.
103133
def default_config
134+
return @noop_config if disabled?
104135
unless defined?(@default_config)
105136
raise NotYetConfiguredError, "Default policy not yet configured"
106137
end
@@ -116,6 +147,19 @@ def deep_copy_if_hash(value)
116147
value
117148
end
118149
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
119163
end
120164

121165
CONFIG_ATTRIBUTES_TO_HEADER_CLASSES = {
@@ -131,6 +175,7 @@ def deep_copy_if_hash(value)
131175
csp: ContentSecurityPolicy,
132176
csp_report_only: ContentSecurityPolicy,
133177
cookies: Cookie,
178+
reporting_endpoints: ReportingEndpoints,
134179
}.freeze
135180

136181
CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze
@@ -167,6 +212,7 @@ def initialize(&block)
167212
@x_permitted_cross_domain_policies = nil
168213
@x_xss_protection = nil
169214
@expect_certificate_transparency = nil
215+
@reporting_endpoints = nil
170216

171217
self.referrer_policy = OPT_OUT
172218
self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
@@ -192,6 +238,7 @@ def dup
192238
copy.clear_site_data = @clear_site_data
193239
copy.expect_certificate_transparency = @expect_certificate_transparency
194240
copy.referrer_policy = @referrer_policy
241+
copy.reporting_endpoints = self.class.send(:deep_copy_if_hash, @reporting_endpoints)
195242
copy
196243
end
197244

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def build_value
6363
build_sandbox_list_directive(directive_name)
6464
when :media_type_list
6565
build_media_type_list_directive(directive_name)
66+
when :report_to_endpoint
67+
build_report_to_directive(directive_name)
6668
end
6769
end.compact.join("; ")
6870
end
@@ -100,6 +102,13 @@ def build_media_type_list_directive(directive)
100102
end
101103
end
102104

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), endpoint_name].join(" ")
109+
end
110+
end
111+
103112
# Private: builds a string that represents one directive in a minified form.
104113
#
105114
# directive_name - a symbol representing the various ALL_DIRECTIVES
@@ -129,6 +138,7 @@ def minify_source_list(directive, source_list)
129138
else
130139
source_list = populate_nonces(directive, source_list)
131140
source_list = reject_all_values_if_none(source_list)
141+
source_list = normalize_uri_paths(source_list)
132142

133143
unless directive == REPORT_URI || @preserve_schemes
134144
source_list = strip_source_schemes(source_list)
@@ -151,6 +161,26 @@ def reject_all_values_if_none(source_list)
151161
end
152162
end
153163

164+
def normalize_uri_paths(source_list)
165+
source_list.map do |source|
166+
# Normalize domains ending in a single / as without omitting the slash accomplishes the same.
167+
# https://www.w3.org/TR/CSP3/#match-paths § 6.6.2.10 Step 2
168+
begin
169+
uri = URI(source)
170+
if uri.path == "/"
171+
next source.chomp("/")
172+
end
173+
rescue URI::InvalidURIError
174+
end
175+
176+
if source.chomp("/").include?("/")
177+
source
178+
else
179+
source.chomp("/")
180+
end
181+
end
182+
end
183+
154184
# Private: append a nonce to the script/style directories if script_nonce
155185
# or style_nonce are provided.
156186
def populate_nonces(directive, source_list)
@@ -179,11 +209,12 @@ def append_nonce(source_list, nonce)
179209
end
180210

181211
# Private: return the list of directives,
182-
# starting with default-src and ending with report-uri.
212+
# starting with default-src and ending with reporting directives (alphabetically ordered).
183213
def directives
184214
[
185215
DEFAULT_SRC,
186216
BODY_DIRECTIVES,
217+
REPORT_TO,
187218
REPORT_URI,
188219
].flatten
189220
end

lib/secure_headers/headers/policy_management.rb

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def self.included(base)
3939
SCRIPT_SRC = :script_src
4040
STYLE_SRC = :style_src
4141
REPORT_URI = :report_uri
42+
REPORT_TO = :report_to
4243

4344
DIRECTIVES_1_0 = [
4445
DEFAULT_SRC,
@@ -51,7 +52,8 @@ def self.included(base)
5152
SANDBOX,
5253
SCRIPT_SRC,
5354
STYLE_SRC,
54-
REPORT_URI
55+
REPORT_URI,
56+
REPORT_TO
5557
].freeze
5658

5759
BASE_URI = :base_uri
@@ -110,9 +112,9 @@ def self.included(base)
110112

111113
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort
112114

113-
# Think of default-src and report-uri as the beginning and end respectively,
115+
# Think of default-src and report-uri/report-to as the beginning and end respectively,
114116
# everything else is in between.
115-
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
117+
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI, REPORT_TO]
116118

117119
DIRECTIVE_VALUE_TYPES = {
118120
BASE_URI => :source_list,
@@ -129,10 +131,11 @@ def self.included(base)
129131
NAVIGATE_TO => :source_list,
130132
OBJECT_SRC => :source_list,
131133
PLUGIN_TYPES => :media_type_list,
134+
PREFETCH_SRC => :source_list,
135+
REPORT_TO => :report_to_endpoint,
136+
REPORT_URI => :source_list,
132137
REQUIRE_SRI_FOR => :require_sri_for_list,
133138
REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list,
134-
REPORT_URI => :source_list,
135-
PREFETCH_SRC => :source_list,
136139
SANDBOX => :sandbox_list,
137140
SCRIPT_SRC => :source_list,
138141
SCRIPT_SRC_ELEM => :source_list,
@@ -158,6 +161,7 @@ def self.included(base)
158161
FORM_ACTION,
159162
FRAME_ANCESTORS,
160163
NAVIGATE_TO,
164+
REPORT_TO,
161165
REPORT_URI,
162166
]
163167

@@ -344,6 +348,8 @@ def validate_directive!(directive, value)
344348
validate_require_sri_source_expression!(directive, value)
345349
when :require_trusted_types_for_list
346350
validate_require_trusted_types_for_source_expression!(directive, value)
351+
when :report_to_endpoint
352+
validate_report_to_endpoint_expression!(directive, value)
347353
else
348354
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
349355
end
@@ -398,6 +404,18 @@ def validate_require_trusted_types_for_source_expression!(directive, require_tru
398404
end
399405
end
400406

407+
# Private: validates that a report-to endpoint expression:
408+
# 1. is a string
409+
# 2. is not empty
410+
def validate_report_to_endpoint_expression!(directive, endpoint_name)
411+
unless endpoint_name.is_a?(String)
412+
raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{endpoint_name.class} value")
413+
end
414+
if endpoint_name.empty?
415+
raise ContentSecurityPolicyConfigError.new("#{directive} must not be empty")
416+
end
417+
end
418+
401419
# Private: validates that a source expression:
402420
# 1. is an array of strings
403421
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)

0 commit comments

Comments
 (0)