secure_headers 7.1.0 → 7.2.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 +4 -4
- data/CHANGELOG.md +2 -2
- data/README.md +65 -1
- data/lib/secure_headers/configuration.rb +53 -6
- data/lib/secure_headers/headers/clear_site_data.rb +30 -32
- data/lib/secure_headers/headers/content_security_policy.rb +34 -3
- data/lib/secure_headers/headers/cookie.rb +2 -4
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +19 -21
- data/lib/secure_headers/headers/policy_management.rb +25 -7
- data/lib/secure_headers/headers/referrer_policy.rb +19 -21
- data/lib/secure_headers/headers/reporting_endpoints.rb +54 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +12 -14
- data/lib/secure_headers/headers/x_content_type_options.rb +13 -15
- data/lib/secure_headers/headers/x_download_options.rb +13 -15
- data/lib/secure_headers/headers/x_frame_options.rb +13 -15
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +13 -15
- data/lib/secure_headers/headers/x_xss_protection.rb +12 -14
- data/lib/secure_headers/middleware.rb +11 -7
- data/lib/secure_headers/railtie.rb +6 -3
- data/lib/secure_headers/task_helper.rb +65 -0
- data/lib/secure_headers/version.rb +1 -1
- data/lib/secure_headers.rb +26 -2
- data/lib/tasks/tasks.rake +4 -53
- data/secure_headers.gemspec +2 -0
- metadata +20 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c43b0dda7b2739b7309a887a98fb5a7c81120fd10d3f7eec5b15ad5afb3ef05
|
|
4
|
+
data.tar.gz: d33d9f60cbd50e3085d1b5f0946007af6cd0f56ad179bbf393c2713151f490d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: afca952f75511ad4d5e0d6c9513fa68560fe5ed1843c445986fba36a3fca6fbf2fc192279f0341a917ac85ca89af19eb7c199b76ce57e93bed54875e3bd24f7d
|
|
7
|
+
data.tar.gz: eb9138a561acea12ed129f152425861e6405b3df999393c5dcf9bd99f5405a0f1a328dfee47c638a85333b1d71ff6e9ce9ebfaad954d84f9f984454a20958a53
|
data/CHANGELOG.md
CHANGED
|
@@ -70,7 +70,7 @@ NOTE: this version is a breaking change due to the removal of HPKP. Remove the H
|
|
|
70
70
|
|
|
71
71
|
## 5.0.0
|
|
72
72
|
|
|
73
|
-
Well this is a little
|
|
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.
|
|
74
74
|
|
|
75
75
|
## 4.0.1
|
|
76
76
|
|
|
@@ -194,7 +194,7 @@ end
|
|
|
194
194
|
|
|
195
195
|
## 3.4.0 the frame-src/child-src transition for Firefox.
|
|
196
196
|
|
|
197
|
-
Handle the `child-src`/`frame-src` transition semi-intelligently across versions. I think the code best
|
|
197
|
+
Handle the `child-src`/`frame-src` transition semi-intelligently across versions. I think the code best describes the behavior here:
|
|
198
198
|
|
|
199
199
|
```ruby
|
|
200
200
|
if supported_directives.include?(:child_src)
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Secure Headers ](https://github.com/github/secure_headers/actions/workflows/build.yml)
|
|
2
2
|
|
|
3
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
|
|
|
@@ -88,9 +88,50 @@ SecureHeaders::Configuration.default do |config|
|
|
|
88
88
|
img_src: %w(somewhereelse.com),
|
|
89
89
|
report_uri: %w(https://report-uri.io/example-csp-report-only)
|
|
90
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
|
+
}
|
|
91
102
|
end
|
|
92
103
|
```
|
|
93
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
|
+
|
|
94
135
|
### Deprecated Configuration Values
|
|
95
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.
|
|
96
137
|
|
|
@@ -125,6 +166,29 @@ end
|
|
|
125
166
|
|
|
126
167
|
However, I would consider these headers anyways depending on your load and bandwidth requirements.
|
|
127
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
|
+
|
|
128
192
|
## Acknowledgements
|
|
129
193
|
|
|
130
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)
|
|
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
|
|
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
|
#
|
|
@@ -101,6 +131,7 @@ module SecureHeaders
|
|
|
101
131
|
# of ensuring that the default config is never mutated and is dup(ed)
|
|
102
132
|
# before it is used in a request.
|
|
103
133
|
def default_config
|
|
134
|
+
return @noop_config if disabled?
|
|
104
135
|
unless defined?(@default_config)
|
|
105
136
|
raise NotYetConfiguredError, "Default policy not yet configured"
|
|
106
137
|
end
|
|
@@ -116,6 +147,19 @@ module SecureHeaders
|
|
|
116
147
|
value
|
|
117
148
|
end
|
|
118
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
|
|
119
163
|
end
|
|
120
164
|
|
|
121
165
|
CONFIG_ATTRIBUTES_TO_HEADER_CLASSES = {
|
|
@@ -131,6 +175,7 @@ module SecureHeaders
|
|
|
131
175
|
csp: ContentSecurityPolicy,
|
|
132
176
|
csp_report_only: ContentSecurityPolicy,
|
|
133
177
|
cookies: Cookie,
|
|
178
|
+
reporting_endpoints: ReportingEndpoints,
|
|
134
179
|
}.freeze
|
|
135
180
|
|
|
136
181
|
CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze
|
|
@@ -167,6 +212,7 @@ module SecureHeaders
|
|
|
167
212
|
@x_permitted_cross_domain_policies = nil
|
|
168
213
|
@x_xss_protection = nil
|
|
169
214
|
@expect_certificate_transparency = nil
|
|
215
|
+
@reporting_endpoints = nil
|
|
170
216
|
|
|
171
217
|
self.referrer_policy = OPT_OUT
|
|
172
218
|
self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
|
|
@@ -192,6 +238,7 @@ module SecureHeaders
|
|
|
192
238
|
copy.clear_site_data = @clear_site_data
|
|
193
239
|
copy.expect_certificate_transparency = @expect_certificate_transparency
|
|
194
240
|
copy.referrer_policy = @referrer_policy
|
|
241
|
+
copy.reporting_endpoints = self.class.send(:deep_copy_if_hash, @reporting_endpoints)
|
|
195
242
|
copy
|
|
196
243
|
end
|
|
197
244
|
|
|
@@ -11,43 +11,41 @@ module SecureHeaders
|
|
|
11
11
|
EXECUTION_CONTEXTS = "executionContexts".freeze
|
|
12
12
|
ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
@@ -63,6 +63,8 @@ module SecureHeaders
|
|
|
63
63
|
build_sandbox_list_directive(directive_name)
|
|
64
64
|
when :media_type_list
|
|
65
65
|
build_media_type_list_directive(directive_name)
|
|
66
|
+
when :report_to_endpoint
|
|
67
|
+
build_report_to_directive(directive_name)
|
|
66
68
|
end
|
|
67
69
|
end.compact.join("; ")
|
|
68
70
|
end
|
|
@@ -79,7 +81,7 @@ module SecureHeaders
|
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
# A maximally strict sandbox policy is just the `sandbox` directive,
|
|
82
|
-
#
|
|
84
|
+
# with no configuration values.
|
|
83
85
|
if max_strict_policy
|
|
84
86
|
symbol_to_hyphen_case(directive)
|
|
85
87
|
elsif sandbox_list && sandbox_list.any?
|
|
@@ -100,6 +102,13 @@ module SecureHeaders
|
|
|
100
102
|
end
|
|
101
103
|
end
|
|
102
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), endpoint_name].join(" ")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
103
112
|
# Private: builds a string that represents one directive in a minified form.
|
|
104
113
|
#
|
|
105
114
|
# directive_name - a symbol representing the various ALL_DIRECTIVES
|
|
@@ -120,7 +129,7 @@ module SecureHeaders
|
|
|
120
129
|
end
|
|
121
130
|
|
|
122
131
|
# If a directive contains *, all other values are omitted.
|
|
123
|
-
# If a directive contains 'none' but has other values, 'none' is
|
|
132
|
+
# If a directive contains 'none' but has other values, 'none' is omitted.
|
|
124
133
|
# Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
|
|
125
134
|
def minify_source_list(directive, source_list)
|
|
126
135
|
source_list = source_list.compact
|
|
@@ -129,6 +138,7 @@ module SecureHeaders
|
|
|
129
138
|
else
|
|
130
139
|
source_list = populate_nonces(directive, source_list)
|
|
131
140
|
source_list = reject_all_values_if_none(source_list)
|
|
141
|
+
source_list = normalize_uri_paths(source_list)
|
|
132
142
|
|
|
133
143
|
unless directive == REPORT_URI || @preserve_schemes
|
|
134
144
|
source_list = strip_source_schemes(source_list)
|
|
@@ -151,6 +161,26 @@ module SecureHeaders
|
|
|
151
161
|
end
|
|
152
162
|
end
|
|
153
163
|
|
|
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
|
+
|
|
154
184
|
# Private: append a nonce to the script/style directories if script_nonce
|
|
155
185
|
# or style_nonce are provided.
|
|
156
186
|
def populate_nonces(directive, source_list)
|
|
@@ -179,11 +209,12 @@ module SecureHeaders
|
|
|
179
209
|
end
|
|
180
210
|
|
|
181
211
|
# Private: return the list of directives,
|
|
182
|
-
# starting with default-src and ending with
|
|
212
|
+
# starting with default-src and ending with reporting directives (alphabetically ordered).
|
|
183
213
|
def directives
|
|
184
214
|
[
|
|
185
215
|
DEFAULT_SRC,
|
|
186
216
|
BODY_DIRECTIVES,
|
|
217
|
+
REPORT_TO,
|
|
187
218
|
REPORT_URI,
|
|
188
219
|
].flatten
|
|
189
220
|
end
|
|
@@ -7,10 +7,8 @@ module SecureHeaders
|
|
|
7
7
|
class CookiesConfigError < StandardError; end
|
|
8
8
|
class Cookie
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
CookiesConfig.new(config).validate!
|
|
13
|
-
end
|
|
10
|
+
def self.validate_config!(config)
|
|
11
|
+
CookiesConfig.new(config).validate!
|
|
14
12
|
end
|
|
15
13
|
|
|
16
14
|
attr_reader :raw_cookie, :config
|
|
@@ -9,31 +9,29 @@ module SecureHeaders
|
|
|
9
9
|
REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
|
|
10
10
|
INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return if config.nil? || config == OPT_OUT
|
|
12
|
+
# Public: Generate a expect-ct header.
|
|
13
|
+
#
|
|
14
|
+
# Returns nil if not configured, returns header name and value if
|
|
15
|
+
# configured.
|
|
16
|
+
def self.make_header(config, use_agent = nil)
|
|
17
|
+
return if config.nil? || config == OPT_OUT
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
header = new(config)
|
|
20
|
+
[HEADER_NAME, header.value]
|
|
21
|
+
end
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
def self.validate_config!(config)
|
|
24
|
+
return if config.nil? || config == OPT_OUT
|
|
25
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
unless [true, false, nil].include?(config[:enforce])
|
|
28
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
|
|
29
|
+
end
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
end
|
|
31
|
+
if !config[:max_age]
|
|
32
|
+
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
|
|
33
|
+
elsif config[:max_age].to_s !~ /\A\d+\z/
|
|
34
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
|
|
37
35
|
end
|
|
38
36
|
end
|
|
39
37
|
|
|
@@ -39,6 +39,7 @@ module SecureHeaders
|
|
|
39
39
|
SCRIPT_SRC = :script_src
|
|
40
40
|
STYLE_SRC = :style_src
|
|
41
41
|
REPORT_URI = :report_uri
|
|
42
|
+
REPORT_TO = :report_to
|
|
42
43
|
|
|
43
44
|
DIRECTIVES_1_0 = [
|
|
44
45
|
DEFAULT_SRC,
|
|
@@ -51,7 +52,8 @@ module SecureHeaders
|
|
|
51
52
|
SANDBOX,
|
|
52
53
|
SCRIPT_SRC,
|
|
53
54
|
STYLE_SRC,
|
|
54
|
-
REPORT_URI
|
|
55
|
+
REPORT_URI,
|
|
56
|
+
REPORT_TO
|
|
55
57
|
].freeze
|
|
56
58
|
|
|
57
59
|
BASE_URI = :base_uri
|
|
@@ -110,9 +112,9 @@ module SecureHeaders
|
|
|
110
112
|
|
|
111
113
|
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort
|
|
112
114
|
|
|
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,
|
|
114
116
|
# 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]
|
|
116
118
|
|
|
117
119
|
DIRECTIVE_VALUE_TYPES = {
|
|
118
120
|
BASE_URI => :source_list,
|
|
@@ -129,10 +131,11 @@ module SecureHeaders
|
|
|
129
131
|
NAVIGATE_TO => :source_list,
|
|
130
132
|
OBJECT_SRC => :source_list,
|
|
131
133
|
PLUGIN_TYPES => :media_type_list,
|
|
134
|
+
PREFETCH_SRC => :source_list,
|
|
135
|
+
REPORT_TO => :report_to_endpoint,
|
|
136
|
+
REPORT_URI => :source_list,
|
|
132
137
|
REQUIRE_SRI_FOR => :require_sri_for_list,
|
|
133
138
|
REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list,
|
|
134
|
-
REPORT_URI => :source_list,
|
|
135
|
-
PREFETCH_SRC => :source_list,
|
|
136
139
|
SANDBOX => :sandbox_list,
|
|
137
140
|
SCRIPT_SRC => :source_list,
|
|
138
141
|
SCRIPT_SRC_ELEM => :source_list,
|
|
@@ -158,6 +161,7 @@ module SecureHeaders
|
|
|
158
161
|
FORM_ACTION,
|
|
159
162
|
FRAME_ANCESTORS,
|
|
160
163
|
NAVIGATE_TO,
|
|
164
|
+
REPORT_TO,
|
|
161
165
|
REPORT_URI,
|
|
162
166
|
]
|
|
163
167
|
|
|
@@ -201,7 +205,7 @@ module SecureHeaders
|
|
|
201
205
|
|
|
202
206
|
# Public: Validates each source expression.
|
|
203
207
|
#
|
|
204
|
-
# Does not validate the
|
|
208
|
+
# Does not validate the individual values of the source expression (e.g.
|
|
205
209
|
# script_src => h*t*t*p: will not raise an exception)
|
|
206
210
|
def validate_config!(config)
|
|
207
211
|
return if config.nil? || config.opt_out?
|
|
@@ -344,6 +348,8 @@ module SecureHeaders
|
|
|
344
348
|
validate_require_sri_source_expression!(directive, value)
|
|
345
349
|
when :require_trusted_types_for_list
|
|
346
350
|
validate_require_trusted_types_for_source_expression!(directive, value)
|
|
351
|
+
when :report_to_endpoint
|
|
352
|
+
validate_report_to_endpoint_expression!(directive, value)
|
|
347
353
|
else
|
|
348
354
|
raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
|
|
349
355
|
end
|
|
@@ -398,11 +404,23 @@ module SecureHeaders
|
|
|
398
404
|
end
|
|
399
405
|
end
|
|
400
406
|
|
|
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
|
+
|
|
401
419
|
# Private: validates that a source expression:
|
|
402
420
|
# 1. is an array of strings
|
|
403
421
|
# 2. does not contain any deprecated, now invalid values (inline, eval, self, none)
|
|
404
422
|
#
|
|
405
|
-
# Does not validate the
|
|
423
|
+
# Does not validate the individual values of the source expression (e.g.
|
|
406
424
|
# script_src => h*t*t*p: will not raise an exception)
|
|
407
425
|
def validate_source_expression!(directive, source_expression)
|
|
408
426
|
if source_expression != OPT_OUT
|
|
@@ -15,29 +15,27 @@ module SecureHeaders
|
|
|
15
15
|
unsafe-url
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
end
|
|
18
|
+
# Public: generate an Referrer Policy header.
|
|
19
|
+
#
|
|
20
|
+
# Returns a default header if no configuration is provided, or a
|
|
21
|
+
# header name and value based on the config.
|
|
22
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
23
|
+
return if config == OPT_OUT
|
|
24
|
+
config ||= DEFAULT_VALUE
|
|
25
|
+
[HEADER_NAME, Array(config).join(", ")]
|
|
26
|
+
end
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
38
|
-
else
|
|
39
|
-
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
|
|
28
|
+
def self.validate_config!(config)
|
|
29
|
+
case config
|
|
30
|
+
when nil, OPT_OUT
|
|
31
|
+
# valid
|
|
32
|
+
when String, Array
|
|
33
|
+
config = Array(config)
|
|
34
|
+
unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
|
|
35
|
+
raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
|
|
40
36
|
end
|
|
37
|
+
else
|
|
38
|
+
raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}")
|
|
41
39
|
end
|
|
42
40
|
end
|
|
43
41
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class ReportingEndpointsConfigError < StandardError; end
|
|
4
|
+
class ReportingEndpoints
|
|
5
|
+
HEADER_NAME = "reporting-endpoints".freeze
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
# Public: generate a Reporting-Endpoints header.
|
|
9
|
+
#
|
|
10
|
+
# The config should be a Hash of endpoint names to URLs.
|
|
11
|
+
# Example: { "csp-endpoint" => "https://example.com/reports" }
|
|
12
|
+
#
|
|
13
|
+
# Returns nil if config is OPT_OUT or nil, or a header name and
|
|
14
|
+
# formatted header value based on the config.
|
|
15
|
+
def make_header(config = nil)
|
|
16
|
+
return if config.nil? || config == OPT_OUT
|
|
17
|
+
validate_config!(config)
|
|
18
|
+
[HEADER_NAME, format_endpoints(config)]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_config!(config)
|
|
22
|
+
case config
|
|
23
|
+
when nil, OPT_OUT
|
|
24
|
+
# valid
|
|
25
|
+
when Hash
|
|
26
|
+
config.each_pair do |name, url|
|
|
27
|
+
if name.is_a?(Symbol)
|
|
28
|
+
name = name.to_s
|
|
29
|
+
end
|
|
30
|
+
unless name.is_a?(String) && !name.empty?
|
|
31
|
+
raise ReportingEndpointsConfigError.new("Endpoint name must be a non-empty string, got: #{name.inspect}")
|
|
32
|
+
end
|
|
33
|
+
unless url.is_a?(String) && !url.empty?
|
|
34
|
+
raise ReportingEndpointsConfigError.new("Endpoint URL must be a non-empty string, got: #{url.inspect}")
|
|
35
|
+
end
|
|
36
|
+
unless url.start_with?("https://")
|
|
37
|
+
raise ReportingEndpointsConfigError.new("Endpoint URLs must use https, got: #{url.inspect}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
raise TypeError.new("Must be a Hash of endpoint names to URLs. Found #{config.class}: #{config}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def format_endpoints(config)
|
|
48
|
+
config.map do |name, url|
|
|
49
|
+
%{#{name}="#{url}"}
|
|
50
|
+
end.join(", ")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -9,21 +9,19 @@ module SecureHeaders
|
|
|
9
9
|
VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i
|
|
10
10
|
MESSAGE = "The config value supplied for the HSTS header was invalid. Must match #{VALID_STS_HEADER}"
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end
|
|
12
|
+
# Public: generate an hsts header name, value pair.
|
|
13
|
+
#
|
|
14
|
+
# Returns a default header if no configuration is provided, or a
|
|
15
|
+
# header name and value based on the config.
|
|
16
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
17
|
+
return if config == OPT_OUT
|
|
18
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
19
|
+
end
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
end
|
|
21
|
+
def self.validate_config!(config)
|
|
22
|
+
return if config.nil? || config == OPT_OUT
|
|
23
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config} #{config.class}") unless config.is_a?(String)
|
|
24
|
+
raise STSConfigError.new(MESSAGE) unless config =~ VALID_STS_HEADER
|
|
27
25
|
end
|
|
28
26
|
end
|
|
29
27
|
end
|
|
@@ -6,22 +6,20 @@ module SecureHeaders
|
|
|
6
6
|
HEADER_NAME = "x-content-type-options".freeze
|
|
7
7
|
DEFAULT_VALUE = "nosniff"
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
9
|
+
# Public: generate an X-Content-Type-Options header.
|
|
10
|
+
#
|
|
11
|
+
# Returns a default header if no configuration is provided, or a
|
|
12
|
+
# header name and value based on the config.
|
|
13
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
14
|
+
return if config == OPT_OUT
|
|
15
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
18
|
+
def self.validate_config!(config)
|
|
19
|
+
return if config.nil? || config == OPT_OUT
|
|
20
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
21
|
+
unless config.casecmp(DEFAULT_VALUE) == 0
|
|
22
|
+
raise XContentTypeOptionsConfigError.new("Value can only be nil or 'nosniff'")
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
end
|
|
@@ -5,22 +5,20 @@ module SecureHeaders
|
|
|
5
5
|
HEADER_NAME = "x-download-options".freeze
|
|
6
6
|
DEFAULT_VALUE = "noopen"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
8
|
+
# Public: generate an x-download-options header.
|
|
9
|
+
#
|
|
10
|
+
# Returns a default header if no configuration is provided, or a
|
|
11
|
+
# header name and value based on the config.
|
|
12
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
13
|
+
return if config == OPT_OUT
|
|
14
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
15
|
+
end
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
17
|
+
def self.validate_config!(config)
|
|
18
|
+
return if config.nil? || config == OPT_OUT
|
|
19
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
20
|
+
unless config.casecmp(DEFAULT_VALUE) == 0
|
|
21
|
+
raise XDOConfigError.new("Value can only be nil or 'noopen'")
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
end
|
|
@@ -10,22 +10,20 @@ module SecureHeaders
|
|
|
10
10
|
DEFAULT_VALUE = SAMEORIGIN
|
|
11
11
|
VALID_XFO_HEADER = /\A(#{SAMEORIGIN}\z|#{DENY}\z|#{ALLOW_ALL}\z|#{ALLOW_FROM}[:\s])/i
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
end
|
|
13
|
+
# Public: generate an X-Frame-Options header.
|
|
14
|
+
#
|
|
15
|
+
# Returns a default header if no configuration is provided, or a
|
|
16
|
+
# header name and value based on the config.
|
|
17
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
18
|
+
return if config == OPT_OUT
|
|
19
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
20
|
+
end
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
22
|
+
def self.validate_config!(config)
|
|
23
|
+
return if config.nil? || config == OPT_OUT
|
|
24
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
25
|
+
unless config =~ VALID_XFO_HEADER
|
|
26
|
+
raise XFOConfigError.new("Value must be SAMEORIGIN|DENY|ALLOW-FROM:|ALLOWALL")
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
end
|
|
@@ -6,22 +6,20 @@ module SecureHeaders
|
|
|
6
6
|
DEFAULT_VALUE = "none"
|
|
7
7
|
VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
9
|
+
# Public: generate an x-permitted-cross-domain-policies header.
|
|
10
|
+
#
|
|
11
|
+
# Returns a default header if no configuration is provided, or a
|
|
12
|
+
# header name and value based on the config.
|
|
13
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
14
|
+
return if config == OPT_OUT
|
|
15
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
18
|
+
def self.validate_config!(config)
|
|
19
|
+
return if config.nil? || config == OPT_OUT
|
|
20
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
21
|
+
unless VALID_POLICIES.include?(config.downcase)
|
|
22
|
+
raise XPCDPConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}")
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
end
|
|
@@ -6,21 +6,19 @@ module SecureHeaders
|
|
|
6
6
|
DEFAULT_VALUE = "0".freeze
|
|
7
7
|
VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
9
|
+
# Public: generate an X-Xss-Protection header.
|
|
10
|
+
#
|
|
11
|
+
# Returns a default header if no configuration is provided, or a
|
|
12
|
+
# header name and value based on the config.
|
|
13
|
+
def self.make_header(config = nil, user_agent = nil)
|
|
14
|
+
return if config == OPT_OUT
|
|
15
|
+
[HEADER_NAME, config || DEFAULT_VALUE]
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
18
|
+
def self.validate_config!(config)
|
|
19
|
+
return if config.nil? || config == OPT_OUT
|
|
20
|
+
raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
|
|
21
|
+
raise XXssProtectionConfigError.new("Invalid format (see VALID_X_XSS_HEADER)") unless config.to_s =~ VALID_X_XSS_HEADER
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
end
|
|
@@ -10,6 +10,12 @@ module SecureHeaders
|
|
|
10
10
|
req = Rack::Request.new(env)
|
|
11
11
|
status, headers, response = @app.call(env)
|
|
12
12
|
|
|
13
|
+
# Rack::Headers is available in Rack 3.x and later
|
|
14
|
+
# So we should pull the headers into that structure if possible
|
|
15
|
+
if defined?(Rack::Headers)
|
|
16
|
+
headers = Rack::Headers[headers]
|
|
17
|
+
end
|
|
18
|
+
|
|
13
19
|
config = SecureHeaders.config_for(req)
|
|
14
20
|
flag_cookies!(headers, override_secure(env, config.cookies)) unless config.cookies == OPT_OUT
|
|
15
21
|
headers.merge!(SecureHeaders.header_hash_for(req))
|
|
@@ -20,14 +26,12 @@ module SecureHeaders
|
|
|
20
26
|
|
|
21
27
|
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
|
|
22
28
|
def flag_cookies!(headers, config)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
cookies = cookies.split("\n") unless cookies.is_a?(Array)
|
|
29
|
+
cookies = headers["Set-Cookie"]
|
|
30
|
+
return unless cookies
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
32
|
+
cookies_array = cookies.is_a?(Array) ? cookies : cookies.split("\n")
|
|
33
|
+
secured_cookies = cookies_array.map { |cookie| SecureHeaders::Cookie.new(cookie, config).to_s }
|
|
34
|
+
headers["Set-Cookie"] = cookies.is_a?(Array) ? secured_cookies : secured_cookies.join("\n")
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
# disable Secure cookies for non-https requests
|
|
@@ -22,9 +22,12 @@ if defined?(Rails::Railtie)
|
|
|
22
22
|
ActiveSupport.on_load(:action_controller) do
|
|
23
23
|
include SecureHeaders
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
default_headers = Rails.application.config.action_dispatch.default_headers
|
|
26
|
+
unless default_headers.nil?
|
|
27
|
+
default_headers.each_key do |header|
|
|
28
|
+
if conflicting_headers.include?(header.downcase)
|
|
29
|
+
default_headers.delete(header)
|
|
30
|
+
end
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecureHeaders
|
|
4
|
+
module TaskHelper
|
|
5
|
+
include SecureHeaders::HashHelper
|
|
6
|
+
|
|
7
|
+
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
|
|
8
|
+
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx
|
|
9
|
+
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
|
|
10
|
+
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
|
|
11
|
+
|
|
12
|
+
def generate_inline_script_hashes(filename)
|
|
13
|
+
hashes = []
|
|
14
|
+
|
|
15
|
+
hashes.concat find_inline_content(filename, INLINE_SCRIPT_REGEX, false)
|
|
16
|
+
hashes.concat find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, true)
|
|
17
|
+
|
|
18
|
+
hashes
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def generate_inline_style_hashes(filename)
|
|
22
|
+
hashes = []
|
|
23
|
+
|
|
24
|
+
hashes.concat find_inline_content(filename, INLINE_STYLE_REGEX, false)
|
|
25
|
+
hashes.concat find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, true)
|
|
26
|
+
|
|
27
|
+
hashes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dynamic_content?(filename, inline_script)
|
|
31
|
+
!!(
|
|
32
|
+
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
|
|
33
|
+
(is_erb?(filename) && inline_script =~ /<%.*%>/)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def find_inline_content(filename, regex, strip_trailing_whitespace)
|
|
40
|
+
hashes = []
|
|
41
|
+
file = File.read(filename)
|
|
42
|
+
file.scan(regex) do # TODO don't use gsub
|
|
43
|
+
inline_script = Regexp.last_match.captures.last
|
|
44
|
+
inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace
|
|
45
|
+
if dynamic_content?(filename, inline_script)
|
|
46
|
+
puts "Looks like there's some dynamic content inside of a tag :-/"
|
|
47
|
+
puts "That pretty much means the hash value will never match."
|
|
48
|
+
puts "Code: " + inline_script
|
|
49
|
+
puts "=" * 20
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
hashes << hash_source(inline_script)
|
|
53
|
+
end
|
|
54
|
+
hashes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def is_erb?(filename)
|
|
58
|
+
filename =~ /\.erb\Z/
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def is_mustache?(filename)
|
|
62
|
+
filename =~ /\.mustache\Z/
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/secure_headers.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "secure_headers/headers/x_permitted_cross_domain_policies"
|
|
|
11
11
|
require "secure_headers/headers/referrer_policy"
|
|
12
12
|
require "secure_headers/headers/clear_site_data"
|
|
13
13
|
require "secure_headers/headers/expect_certificate_transparency"
|
|
14
|
+
require "secure_headers/headers/reporting_endpoints"
|
|
14
15
|
require "secure_headers/middleware"
|
|
15
16
|
require "secure_headers/railtie"
|
|
16
17
|
require "secure_headers/view_helper"
|
|
@@ -133,6 +134,7 @@ module SecureHeaders
|
|
|
133
134
|
# request.
|
|
134
135
|
#
|
|
135
136
|
# StrictTransportSecurity is not applied to http requests.
|
|
137
|
+
# upgrade_insecure_requests is not applied to http requests.
|
|
136
138
|
# See #config_for to determine which config is used for a given request.
|
|
137
139
|
#
|
|
138
140
|
# Returns a hash of header names => header values. The value
|
|
@@ -146,6 +148,11 @@ module SecureHeaders
|
|
|
146
148
|
|
|
147
149
|
if request.scheme != HTTPS
|
|
148
150
|
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)
|
|
149
156
|
end
|
|
150
157
|
headers
|
|
151
158
|
end
|
|
@@ -178,7 +185,7 @@ module SecureHeaders
|
|
|
178
185
|
content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC)
|
|
179
186
|
end
|
|
180
187
|
|
|
181
|
-
# Public:
|
|
188
|
+
# Public: Retrieves the config for a given header type:
|
|
182
189
|
#
|
|
183
190
|
# Checks to see if there is an override for this request, then
|
|
184
191
|
# Checks to see if a named override is used for this request, then
|
|
@@ -208,7 +215,7 @@ module SecureHeaders
|
|
|
208
215
|
|
|
209
216
|
def config_and_target(request, target)
|
|
210
217
|
config = config_for(request)
|
|
211
|
-
target
|
|
218
|
+
target ||= guess_target(config)
|
|
212
219
|
raise_on_unknown_target(target)
|
|
213
220
|
[config, target]
|
|
214
221
|
end
|
|
@@ -242,6 +249,23 @@ module SecureHeaders
|
|
|
242
249
|
def override_secure_headers_request_config(request, config)
|
|
243
250
|
request.env[SECURE_HEADERS_CONFIG] = config
|
|
244
251
|
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
|
|
245
269
|
end
|
|
246
270
|
|
|
247
271
|
# These methods are mixed into controllers and delegate to the class method
|
data/lib/tasks/tasks.rake
CHANGED
|
@@ -1,58 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX
|
|
4
|
-
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX
|
|
5
|
-
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX
|
|
2
|
+
require "secure_headers/task_helper"
|
|
6
3
|
|
|
7
4
|
namespace :secure_headers do
|
|
8
|
-
include SecureHeaders::
|
|
9
|
-
|
|
10
|
-
def is_erb?(filename)
|
|
11
|
-
filename =~ /\.erb\Z/
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def is_mustache?(filename)
|
|
15
|
-
filename =~ /\.mustache\Z/
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def dynamic_content?(filename, inline_script)
|
|
19
|
-
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
|
|
20
|
-
(is_erb?(filename) && inline_script =~ /<%.*%>/)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def find_inline_content(filename, regex, hashes, strip_trailing_whitespace)
|
|
24
|
-
file = File.read(filename)
|
|
25
|
-
file.scan(regex) do # TODO don't use gsub
|
|
26
|
-
inline_script = Regexp.last_match.captures.last
|
|
27
|
-
inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace
|
|
28
|
-
if dynamic_content?(filename, inline_script)
|
|
29
|
-
puts "Looks like there's some dynamic content inside of a tag :-/"
|
|
30
|
-
puts "That pretty much means the hash value will never match."
|
|
31
|
-
puts "Code: " + inline_script
|
|
32
|
-
puts "=" * 20
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
hashes << hash_source(inline_script)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def generate_inline_script_hashes(filename)
|
|
40
|
-
hashes = []
|
|
41
|
-
|
|
42
|
-
find_inline_content(filename, INLINE_SCRIPT_REGEX, hashes, false)
|
|
43
|
-
find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, hashes, true)
|
|
44
|
-
|
|
45
|
-
hashes
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def generate_inline_style_hashes(filename)
|
|
49
|
-
hashes = []
|
|
50
|
-
|
|
51
|
-
find_inline_content(filename, INLINE_STYLE_REGEX, hashes, false)
|
|
52
|
-
find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, hashes, true)
|
|
53
|
-
|
|
54
|
-
hashes
|
|
55
|
-
end
|
|
5
|
+
include SecureHeaders::TaskHelper
|
|
56
6
|
|
|
57
7
|
desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
58
8
|
task :generate_hashes do |t, args|
|
|
@@ -77,6 +27,7 @@ namespace :secure_headers do
|
|
|
77
27
|
file.write(script_hashes.to_yaml)
|
|
78
28
|
end
|
|
79
29
|
|
|
80
|
-
|
|
30
|
+
file_count = (script_hashes["scripts"].keys + script_hashes["styles"].keys).uniq.count
|
|
31
|
+
puts "Script and style hashes from #{file_count} files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
81
32
|
end
|
|
82
33
|
end
|
data/secure_headers.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: secure_headers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 7.
|
|
4
|
+
version: 7.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Neil Matatall
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: cgi
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
13
26
|
- !ruby/object:Gem::Dependency
|
|
14
27
|
name: rake
|
|
15
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -33,9 +46,9 @@ email:
|
|
|
33
46
|
executables: []
|
|
34
47
|
extensions: []
|
|
35
48
|
extra_rdoc_files:
|
|
36
|
-
- README.md
|
|
37
49
|
- CHANGELOG.md
|
|
38
50
|
- LICENSE
|
|
51
|
+
- README.md
|
|
39
52
|
files:
|
|
40
53
|
- CHANGELOG.md
|
|
41
54
|
- Gemfile
|
|
@@ -51,6 +64,7 @@ files:
|
|
|
51
64
|
- lib/secure_headers/headers/expect_certificate_transparency.rb
|
|
52
65
|
- lib/secure_headers/headers/policy_management.rb
|
|
53
66
|
- lib/secure_headers/headers/referrer_policy.rb
|
|
67
|
+
- lib/secure_headers/headers/reporting_endpoints.rb
|
|
54
68
|
- lib/secure_headers/headers/strict_transport_security.rb
|
|
55
69
|
- lib/secure_headers/headers/x_content_type_options.rb
|
|
56
70
|
- lib/secure_headers/headers/x_download_options.rb
|
|
@@ -59,6 +73,7 @@ files:
|
|
|
59
73
|
- lib/secure_headers/headers/x_xss_protection.rb
|
|
60
74
|
- lib/secure_headers/middleware.rb
|
|
61
75
|
- lib/secure_headers/railtie.rb
|
|
76
|
+
- lib/secure_headers/task_helper.rb
|
|
62
77
|
- lib/secure_headers/utils/cookies_config.rb
|
|
63
78
|
- lib/secure_headers/version.rb
|
|
64
79
|
- lib/secure_headers/view_helper.rb
|
|
@@ -74,7 +89,6 @@ metadata:
|
|
|
74
89
|
homepage_uri: https://github.com/github/secure_headers
|
|
75
90
|
source_code_uri: https://github.com/github/secure_headers
|
|
76
91
|
rubygems_mfa_required: 'true'
|
|
77
|
-
post_install_message:
|
|
78
92
|
rdoc_options: []
|
|
79
93
|
require_paths:
|
|
80
94
|
- lib
|
|
@@ -89,8 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
89
103
|
- !ruby/object:Gem::Version
|
|
90
104
|
version: '0'
|
|
91
105
|
requirements: []
|
|
92
|
-
rubygems_version: 3.
|
|
93
|
-
signing_key:
|
|
106
|
+
rubygems_version: 3.6.9
|
|
94
107
|
specification_version: 4
|
|
95
108
|
summary: Manages application of security headers with many safe defaults.
|
|
96
109
|
test_files: []
|