secure_headers 6.7.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -2
  3. data/README.md +78 -14
  4. data/lib/secure_headers/configuration.rb +54 -7
  5. data/lib/secure_headers/headers/clear_site_data.rb +31 -33
  6. data/lib/secure_headers/headers/content_security_policy.rb +61 -13
  7. data/lib/secure_headers/headers/content_security_policy_config.rb +2 -2
  8. data/lib/secure_headers/headers/cookie.rb +2 -4
  9. data/lib/secure_headers/headers/expect_certificate_transparency.rb +20 -22
  10. data/lib/secure_headers/headers/policy_management.rb +27 -9
  11. data/lib/secure_headers/headers/referrer_policy.rb +20 -22
  12. data/lib/secure_headers/headers/reporting_endpoints.rb +54 -0
  13. data/lib/secure_headers/headers/strict_transport_security.rb +13 -15
  14. data/lib/secure_headers/headers/x_content_type_options.rb +14 -16
  15. data/lib/secure_headers/headers/x_download_options.rb +14 -16
  16. data/lib/secure_headers/headers/x_frame_options.rb +14 -16
  17. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +14 -16
  18. data/lib/secure_headers/headers/x_xss_protection.rb +14 -16
  19. data/lib/secure_headers/middleware.rb +11 -7
  20. data/lib/secure_headers/railtie.rb +11 -8
  21. data/lib/secure_headers/task_helper.rb +65 -0
  22. data/lib/secure_headers/version.rb +1 -1
  23. data/lib/secure_headers.rb +26 -2
  24. data/lib/tasks/tasks.rake +4 -53
  25. data/secure_headers.gemspec +16 -4
  26. metadata +32 -67
  27. data/.github/ISSUE_TEMPLATE.md +0 -41
  28. data/.github/PULL_REQUEST_TEMPLATE.md +0 -20
  29. data/.github/dependabot.yml +0 -6
  30. data/.github/workflows/build.yml +0 -24
  31. data/.github/workflows/github-release.yml +0 -28
  32. data/.gitignore +0 -13
  33. data/.rspec +0 -3
  34. data/.rubocop.yml +0 -4
  35. data/.ruby-gemset +0 -1
  36. data/.ruby-version +0 -1
  37. data/CODE_OF_CONDUCT.md +0 -46
  38. data/CONTRIBUTING.md +0 -41
  39. data/Guardfile +0 -13
  40. data/Rakefile +0 -32
  41. data/docs/cookies.md +0 -65
  42. data/docs/hashes.md +0 -64
  43. data/docs/named_overrides_and_appends.md +0 -104
  44. data/docs/per_action_configuration.md +0 -139
  45. data/docs/sinatra.md +0 -25
  46. data/docs/upgrading-to-3-0.md +0 -42
  47. data/docs/upgrading-to-4-0.md +0 -35
  48. data/docs/upgrading-to-5-0.md +0 -15
  49. data/docs/upgrading-to-6-0.md +0 -50
  50. data/spec/lib/secure_headers/configuration_spec.rb +0 -121
  51. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +0 -87
  52. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +0 -215
  53. data/spec/lib/secure_headers/headers/cookie_spec.rb +0 -179
  54. data/spec/lib/secure_headers/headers/expect_certificate_transparency_spec.rb +0 -42
  55. data/spec/lib/secure_headers/headers/policy_management_spec.rb +0 -265
  56. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +0 -91
  57. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +0 -33
  58. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +0 -31
  59. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +0 -29
  60. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +0 -36
  61. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +0 -48
  62. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +0 -47
  63. data/spec/lib/secure_headers/middleware_spec.rb +0 -117
  64. data/spec/lib/secure_headers/view_helpers_spec.rb +0 -192
  65. data/spec/lib/secure_headers_spec.rb +0 -516
  66. data/spec/spec_helper.rb +0 -64
@@ -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
@@ -98,9 +100,9 @@ module SecureHeaders
98
100
 
99
101
  # Experimental directives - these vary greatly in support
100
102
  # See MDN for details.
101
- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
103
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/content-security-policy/trusted-types
102
104
  TRUSTED_TYPES = :trusted_types
103
- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/require-trusted-types-for
105
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/content-security-policy/require-trusted-types-for
104
106
  REQUIRE_TRUSTED_TYPES_FOR = :require_trusted_types_for
105
107
 
106
108
  DIRECTIVES_EXPERIMENTAL = [
@@ -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
@@ -2,7 +2,7 @@
2
2
  module SecureHeaders
3
3
  class ReferrerPolicyConfigError < StandardError; end
4
4
  class ReferrerPolicy
5
- HEADER_NAME = "Referrer-Policy".freeze
5
+ HEADER_NAME = "referrer-policy".freeze
6
6
  DEFAULT_VALUE = "origin-when-cross-origin"
7
7
  VALID_POLICIES = %w(
8
8
  no-referrer
@@ -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
@@ -3,27 +3,25 @@ module SecureHeaders
3
3
  class STSConfigError < StandardError; end
4
4
 
5
5
  class StrictTransportSecurity
6
- HEADER_NAME = "Strict-Transport-Security".freeze
6
+ HEADER_NAME = "strict-transport-security".freeze
7
7
  HSTS_MAX_AGE = "631138519"
8
8
  DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE
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
@@ -3,25 +3,23 @@ module SecureHeaders
3
3
  class XContentTypeOptionsConfigError < StandardError; end
4
4
 
5
5
  class XContentTypeOptions
6
- HEADER_NAME = "X-Content-Type-Options".freeze
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
@@ -2,25 +2,23 @@
2
2
  module SecureHeaders
3
3
  class XDOConfigError < StandardError; end
4
4
  class XDownloadOptions
5
- HEADER_NAME = "X-Download-Options".freeze
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
@@ -2,7 +2,7 @@
2
2
  module SecureHeaders
3
3
  class XFOConfigError < StandardError; end
4
4
  class XFrameOptions
5
- HEADER_NAME = "X-Frame-Options".freeze
5
+ HEADER_NAME = "x-frame-options".freeze
6
6
  SAMEORIGIN = "sameorigin"
7
7
  DENY = "deny"
8
8
  ALLOW_FROM = "allow-from"
@@ -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
@@ -2,26 +2,24 @@
2
2
  module SecureHeaders
3
3
  class XPCDPConfigError < StandardError; end
4
4
  class XPermittedCrossDomainPolicies
5
- HEADER_NAME = "X-Permitted-Cross-Domain-Policies".freeze
5
+ HEADER_NAME = "x-permitted-cross-domain-policies".freeze
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
@@ -2,25 +2,23 @@
2
2
  module SecureHeaders
3
3
  class XXssProtectionConfigError < StandardError; end
4
4
  class XXssProtection
5
- HEADER_NAME = "X-XSS-Protection".freeze
6
- DEFAULT_VALUE = "1; mode=block"
5
+ HEADER_NAME = "x-xss-protection".freeze
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
@@ -4,11 +4,11 @@ if defined?(Rails::Railtie)
4
4
  module SecureHeaders
5
5
  class Railtie < Rails::Railtie
6
6
  isolate_namespace SecureHeaders if defined? isolate_namespace # rails 3.0
7
- conflicting_headers = ["X-Frame-Options", "X-XSS-Protection",
8
- "X-Permitted-Cross-Domain-Policies", "X-Download-Options",
9
- "X-Content-Type-Options", "Strict-Transport-Security",
10
- "Content-Security-Policy", "Content-Security-Policy-Report-Only",
11
- "Public-Key-Pins", "Public-Key-Pins-Report-Only", "Referrer-Policy"]
7
+ conflicting_headers = ["x-frame-options", "x-xss-protection",
8
+ "x-permitted-cross-domain-policies", "x-download-options",
9
+ "x-content-type-options", "strict-transport-security",
10
+ "content-security-policy", "content-security-policy-report-only",
11
+ "public-key-pins", "public-key-pins-report-only", "referrer-policy"]
12
12
 
13
13
  initializer "secure_headers.middleware" do
14
14
  Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
@@ -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 = "6.7.0"
4
+ VERSION = "7.3.0"
5
5
  end