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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 484062b599a7d8ca3ad93c0b91bd6f88b9f80eb7b3f5106fbb4b94b0ae7a82f9
4
- data.tar.gz: 68c9dc56b62c0d0c77f166e08ae24e6f13dadcda1a9ebaff8b18e4dad0177fa9
3
+ metadata.gz: 3c43b0dda7b2739b7309a887a98fb5a7c81120fd10d3f7eec5b15ad5afb3ef05
4
+ data.tar.gz: d33d9f60cbd50e3085d1b5f0946007af6cd0f56ad179bbf393c2713151f490d1
5
5
  SHA512:
6
- metadata.gz: 63969aa532b3aa321b2e848764e1ddcbbd9e36fc57bd0e2d98bb4f8ede7c94e32ec6d2aef3f81581d99c1bc623c108d159be635e8df238390304077360fa6f9f
7
- data.tar.gz: fbc1a3a713680ac487ad16185176ebb5cbdce5416bdd9e765faf0757aa0c10a8ef71b825225bfaf40a66c16ce955edacb2deeedc910e14264bd5b8469be8805d
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 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.
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 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:
198
198
 
199
199
  ```ruby
200
200
  if supported_directives.include?(:child_src)
data/README.md CHANGED
@@ -1,4 +1,4 @@
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
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) 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
  #
@@ -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
- 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
@@ -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
- # whith no configuraiton values.
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 ommitted.
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 report-uri.
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
- class << self
11
- def validate_config!(config)
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
- class << self
13
- # Public: Generate a expect-ct header.
14
- #
15
- # Returns nil if not configured, returns header name and value if
16
- # configured.
17
- def make_header(config, use_agent = nil)
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
- header = new(config)
21
- [HEADER_NAME, header.value]
22
- end
19
+ header = new(config)
20
+ [HEADER_NAME, header.value]
21
+ end
23
22
 
24
- def validate_config!(config)
25
- return if config.nil? || config == OPT_OUT
26
- raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
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
- unless [true, false, nil].include?(config[:enforce])
29
- raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
30
- end
27
+ unless [true, false, nil].include?(config[:enforce])
28
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
29
+ end
31
30
 
32
- if !config[:max_age]
33
- raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
34
- elsif config[:max_age].to_s !~ /\A\d+\z/
35
- raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
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 invididual values of the source expression (e.g.
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 invididual values of the source expression (e.g.
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
- class << self
19
- # Public: generate an Referrer Policy header.
20
- #
21
- # Returns a default header if no configuration is provided, or a
22
- # header name and value based on the config.
23
- def make_header(config = nil, user_agent = nil)
24
- return if config == OPT_OUT
25
- config ||= DEFAULT_VALUE
26
- [HEADER_NAME, Array(config).join(", ")]
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
- def validate_config!(config)
30
- case config
31
- when nil, OPT_OUT
32
- # valid
33
- when String, Array
34
- config = Array(config)
35
- unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) }
36
- raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}")
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
- class << self
13
- # Public: generate an hsts header name, value pair.
14
- #
15
- # Returns a default header if no configuration is provided, or a
16
- # header name and value based on the config.
17
- def make_header(config = nil, user_agent = nil)
18
- return if config == OPT_OUT
19
- [HEADER_NAME, config || DEFAULT_VALUE]
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
- def validate_config!(config)
23
- return if config.nil? || config == OPT_OUT
24
- raise TypeError.new("Must be a string. Found #{config.class}: #{config} #{config.class}") unless config.is_a?(String)
25
- raise STSConfigError.new(MESSAGE) unless config =~ VALID_STS_HEADER
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
- class << self
10
- # Public: generate an X-Content-Type-Options header.
11
- #
12
- # Returns a default header if no configuration is provided, or a
13
- # header name and value based on the config.
14
- def make_header(config = nil, user_agent = nil)
15
- return if config == OPT_OUT
16
- [HEADER_NAME, config || DEFAULT_VALUE]
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
- def validate_config!(config)
20
- return if config.nil? || config == OPT_OUT
21
- raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
22
- unless config.casecmp(DEFAULT_VALUE) == 0
23
- raise XContentTypeOptionsConfigError.new("Value can only be nil or 'nosniff'")
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
- class << self
9
- # Public: generate an x-download-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 make_header(config = nil, user_agent = nil)
14
- return if config == OPT_OUT
15
- [HEADER_NAME, config || DEFAULT_VALUE]
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
- def 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 XDOConfigError.new("Value can only be nil or 'noopen'")
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
- class << self
14
- # Public: generate an X-Frame-Options header.
15
- #
16
- # Returns a default header if no configuration is provided, or a
17
- # header name and value based on the config.
18
- def make_header(config = nil, user_agent = nil)
19
- return if config == OPT_OUT
20
- [HEADER_NAME, config || DEFAULT_VALUE]
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
- def validate_config!(config)
24
- return if config.nil? || config == OPT_OUT
25
- raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
26
- unless config =~ VALID_XFO_HEADER
27
- raise XFOConfigError.new("Value must be SAMEORIGIN|DENY|ALLOW-FROM:|ALLOWALL")
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
- class << self
10
- # Public: generate an x-permitted-cross-domain-policies header.
11
- #
12
- # Returns a default header if no configuration is provided, or a
13
- # header name and value based on the config.
14
- def make_header(config = nil, user_agent = nil)
15
- return if config == OPT_OUT
16
- [HEADER_NAME, config || DEFAULT_VALUE]
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
- def validate_config!(config)
20
- return if config.nil? || config == OPT_OUT
21
- raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
22
- unless VALID_POLICIES.include?(config.downcase)
23
- raise XPCDPConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}")
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
- class << self
10
- # Public: generate an X-Xss-Protection header.
11
- #
12
- # Returns a default header if no configuration is provided, or a
13
- # header name and value based on the config.
14
- def make_header(config = nil, user_agent = nil)
15
- return if config == OPT_OUT
16
- [HEADER_NAME, config || DEFAULT_VALUE]
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
- def validate_config!(config)
20
- return if config.nil? || config == OPT_OUT
21
- raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String)
22
- raise XXssProtectionConfigError.new("Invalid format (see VALID_X_XSS_HEADER)") unless config.to_s =~ VALID_X_XSS_HEADER
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
- if cookies = headers["Set-Cookie"]
24
- # Support Rails 2.3 / Rack 1.1 arrays as headers
25
- cookies = cookies.split("\n") unless cookies.is_a?(Array)
29
+ cookies = headers["Set-Cookie"]
30
+ return unless cookies
26
31
 
27
- headers["Set-Cookie"] = cookies.map do |cookie|
28
- SecureHeaders::Cookie.new(cookie, config).to_s
29
- end.join("\n")
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
- unless Rails.application.config.action_dispatch.default_headers.nil?
26
- conflicting_headers.each do |header|
27
- Rails.application.config.action_dispatch.default_headers.delete(header)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecureHeaders
4
- VERSION = "7.1.0"
4
+ VERSION = "7.2.0"
5
5
  end
@@ -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: Retreives the config for a given header type:
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 = guess_target(config) unless 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
- INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX
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::HashHelper
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
- puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
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
@@ -29,5 +29,7 @@ Gem::Specification.new do |gem|
29
29
 
30
30
  gem.extra_rdoc_files = Dir["README.md", "CHANGELOG.md", "LICENSE"]
31
31
 
32
+ gem.add_dependency "cgi", ">= 0.1"
33
+
32
34
  gem.add_development_dependency "rake"
33
35
  end
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.1.0
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: 2024-12-16 00:00:00.000000000 Z
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.0.3.1
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: []