secure_headers 5.2.0 → 6.0.0.alpha01

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.

Potentially problematic release.


This version of secure_headers might be problematic. Click here for more details.

Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +8 -4
  3. data/CHANGELOG.md +3 -7
  4. data/Gemfile +1 -1
  5. data/docs/upgrading-to-6-0.md +50 -0
  6. data/lib/secure_headers.rb +14 -76
  7. data/lib/secure_headers/configuration.rb +114 -164
  8. data/lib/secure_headers/headers/clear_site_data.rb +1 -3
  9. data/lib/secure_headers/headers/content_security_policy.rb +4 -17
  10. data/lib/secure_headers/headers/content_security_policy_config.rb +3 -13
  11. data/lib/secure_headers/headers/expect_certificate_transparency.rb +2 -3
  12. data/lib/secure_headers/headers/policy_management.rb +12 -11
  13. data/lib/secure_headers/headers/public_key_pins.rb +2 -3
  14. data/lib/secure_headers/headers/referrer_policy.rb +2 -2
  15. data/lib/secure_headers/headers/strict_transport_security.rb +2 -2
  16. data/lib/secure_headers/headers/x_content_type_options.rb +2 -2
  17. data/lib/secure_headers/headers/x_download_options.rb +2 -2
  18. data/lib/secure_headers/headers/x_frame_options.rb +1 -2
  19. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +2 -2
  20. data/lib/secure_headers/headers/x_xss_protection.rb +3 -3
  21. data/secure_headers.gemspec +1 -1
  22. data/spec/lib/secure_headers/configuration_spec.rb +15 -70
  23. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +12 -22
  24. data/spec/lib/secure_headers/headers/policy_management_spec.rb +33 -7
  25. data/spec/lib/secure_headers/middleware_spec.rb +7 -1
  26. data/spec/lib/secure_headers/view_helpers_spec.rb +1 -0
  27. data/spec/lib/secure_headers_spec.rb +39 -40
  28. data/spec/spec_helper.rb +7 -3
  29. metadata +5 -4
@@ -11,13 +11,11 @@ module SecureHeaders
11
11
  EXECUTION_CONTEXTS = "executionContexts".freeze
12
12
  ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
13
13
 
14
- CONFIG_KEY = :clear_site_data
15
-
16
14
  class << self
17
15
  # Public: make an Clear-Site-Data header name, value pair
18
16
  #
19
17
  # Returns nil if not configured, returns header name and value if configured.
20
- def make_header(config = nil)
18
+ def make_header(config = nil, user_agent = nil)
21
19
  case config
22
20
  when nil, OPT_OUT, []
23
21
  # noop
@@ -13,6 +13,7 @@ module SecureHeaders
13
13
  FALLBACK_VERSION = ::UserAgent::Version.new("0")
14
14
 
15
15
  def initialize(config = nil, user_agent = OTHER)
16
+ user_agent ||= OTHER
16
17
  @config = if config.is_a?(Hash)
17
18
  if config[:report_only]
18
19
  ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
@@ -138,14 +139,8 @@ module SecureHeaders
138
139
  end
139
140
 
140
141
  if source_list != OPT_OUT && source_list && source_list.any?
141
- minified_source_list = minify_source_list(directive, source_list).join(" ")
142
-
143
- if minified_source_list =~ /(\n|;)/
144
- Kernel.warn("#{directive} contains a #{$1} in #{minified_source_list.inspect} which will raise an error in future versions. It has been replaced with a blank space.")
145
- end
146
-
147
- escaped_source_list = minified_source_list.gsub(/[\n;]/, " ")
148
- [symbol_to_hyphen_case(directive), escaped_source_list].join(" ").strip
142
+ normalized_source_list = minify_source_list(directive, source_list)
143
+ [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
149
144
  end
150
145
  end
151
146
 
@@ -218,11 +213,7 @@ module SecureHeaders
218
213
  # unsafe-inline, this is more concise.
219
214
  def append_nonce(source_list, nonce)
220
215
  if nonce
221
- if nonces_supported?
222
- source_list << "'nonce-#{nonce}'"
223
- else
224
- source_list << UNSAFE_INLINE
225
- end
216
+ source_list.push("'nonce-#{nonce}'", UNSAFE_INLINE)
226
217
  end
227
218
 
228
219
  source_list
@@ -262,10 +253,6 @@ module SecureHeaders
262
253
  end
263
254
  end
264
255
 
265
- def nonces_supported?
266
- @nonces_supported ||= self.class.nonces_supported?(@parsed_ua)
267
- end
268
-
269
256
  def symbol_to_hyphen_case(sym)
270
257
  sym.to_s.tr("_", "-")
271
258
  end
@@ -2,7 +2,6 @@
2
2
  module SecureHeaders
3
3
  module DynamicConfig
4
4
  def self.included(base)
5
- base.send(:attr_writer, :modified)
6
5
  base.send(:attr_reader, *base.attrs)
7
6
  base.attrs.each do |attr|
8
7
  base.send(:define_method, "#{attr}=") do |value|
@@ -42,7 +41,6 @@ module SecureHeaders
42
41
  @upgrade_insecure_requests = nil
43
42
 
44
43
  from_hash(hash)
45
- @modified = false
46
44
  end
47
45
 
48
46
  def update_directive(directive, value)
@@ -55,12 +53,10 @@ module SecureHeaders
55
53
  end
56
54
  end
57
55
 
58
- def modified?
59
- @modified
60
- end
61
-
62
56
  def merge(new_hash)
63
- ContentSecurityPolicy.combine_policies(self.to_h, new_hash)
57
+ new_config = self.dup
58
+ new_config.send(:from_hash, new_hash)
59
+ new_config
64
60
  end
65
61
 
66
62
  def merge!(new_hash)
@@ -109,17 +105,12 @@ module SecureHeaders
109
105
  def write_attribute(attr, value)
110
106
  value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
111
107
  attr_variable = "@#{attr}"
112
- prev_value = self.instance_variable_get(attr_variable)
113
108
  self.instance_variable_set(attr_variable, value)
114
- if prev_value != value
115
- @modified = true
116
- end
117
109
  end
118
110
  end
119
111
 
120
112
  class ContentSecurityPolicyConfigError < StandardError; end
121
113
  class ContentSecurityPolicyConfig
122
- CONFIG_KEY = :csp
123
114
  HEADER_NAME = "Content-Security-Policy".freeze
124
115
 
125
116
  ATTRS = PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
@@ -149,7 +140,6 @@ module SecureHeaders
149
140
  end
150
141
 
151
142
  class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig
152
- CONFIG_KEY = :csp_report_only
153
143
  HEADER_NAME = "Content-Security-Policy-Report-Only".freeze
154
144
 
155
145
  def report_only?
@@ -4,7 +4,6 @@ module SecureHeaders
4
4
 
5
5
  class ExpectCertificateTransparency
6
6
  HEADER_NAME = "Expect-CT".freeze
7
- CONFIG_KEY = :expect_certificate_transparency
8
7
  INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
9
8
  INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze
10
9
  REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
@@ -15,8 +14,8 @@ module SecureHeaders
15
14
  #
16
15
  # Returns nil if not configured, returns header name and value if
17
16
  # configured.
18
- def make_header(config)
19
- return if config.nil?
17
+ def make_header(config, use_agent = nil)
18
+ return if config.nil? || config == OPT_OUT
20
19
 
21
20
  header = new(config)
22
21
  [HEADER_NAME, header.value]
@@ -5,7 +5,6 @@ module SecureHeaders
5
5
  base.extend(ClassMethods)
6
6
  end
7
7
 
8
- MODERN_BROWSERS = %w(Chrome Opera Firefox)
9
8
  DEFAULT_CONFIG = {
10
9
  default_src: %w(https:),
11
10
  img_src: %w(https: data: 'self'),
@@ -200,7 +199,8 @@ module SecureHeaders
200
199
  #
201
200
  # Returns a default policy if no configuration is provided, or a
202
201
  # header name and value based on the config.
203
- def make_header(config, user_agent)
202
+ def make_header(config, user_agent = nil)
203
+ return if config.nil? || config == OPT_OUT
204
204
  header = new(config, user_agent)
205
205
  [header.name, header.value]
206
206
  end
@@ -215,27 +215,28 @@ module SecureHeaders
215
215
  if config.directive_value(:script_src).nil?
216
216
  raise ContentSecurityPolicyConfigError.new(":script_src is required, falling back to default-src is too dangerous. Use `script_src: OPT_OUT` to override")
217
217
  end
218
+ if !config.report_only? && config.directive_value(:report_only)
219
+ raise ContentSecurityPolicyConfigError.new("Only the csp_report_only config should set :report_only to true")
220
+ end
221
+
222
+ if config.report_only? && config.directive_value(:report_only) == false
223
+ raise ContentSecurityPolicyConfigError.new("csp_report_only config must have :report_only set to true")
224
+ end
218
225
 
219
226
  ContentSecurityPolicyConfig.attrs.each do |key|
220
227
  value = config.directive_value(key)
221
228
  next unless value
229
+
222
230
  if META_CONFIGS.include?(key)
223
231
  raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
232
+ elsif NONCES.include?(key)
233
+ raise ContentSecurityPolicyConfigError.new("#{key} must be a non-nil value") if value.nil?
224
234
  else
225
235
  validate_directive!(key, value)
226
236
  end
227
237
  end
228
238
  end
229
239
 
230
- # Public: check if a user agent supports CSP nonces
231
- #
232
- # user_agent - a String or a UserAgent object
233
- def nonces_supported?(user_agent)
234
- user_agent = UserAgent.parse(user_agent) if user_agent.is_a?(String)
235
- MODERN_BROWSERS.include?(user_agent.browser) ||
236
- user_agent.browser == "Safari" && (user_agent.version || CSP::FALLBACK_VERSION) >= CSP::VERSION_10
237
- end
238
-
239
240
  # Public: combine the values from two different configs.
240
241
  #
241
242
  # original - the main config
@@ -5,15 +5,14 @@ module SecureHeaders
5
5
  HEADER_NAME = "Public-Key-Pins".freeze
6
6
  REPORT_ONLY = "Public-Key-Pins-Report-Only".freeze
7
7
  HASH_ALGORITHMS = [:sha256].freeze
8
- CONFIG_KEY = :hpkp
9
8
 
10
9
 
11
10
  class << self
12
11
  # Public: make an hpkp header name, value pair
13
12
  #
14
13
  # Returns nil if not configured, returns header name and value if configured.
15
- def make_header(config)
16
- return if config.nil?
14
+ def make_header(config, user_agent = nil)
15
+ return if config.nil? || config == OPT_OUT
17
16
  header = new(config)
18
17
  [header.name, header.value]
19
18
  end
@@ -14,14 +14,14 @@ module SecureHeaders
14
14
  origin-when-cross-origin
15
15
  unsafe-url
16
16
  )
17
- CONFIG_KEY = :referrer_policy
18
17
 
19
18
  class << self
20
19
  # Public: generate an Referrer Policy header.
21
20
  #
22
21
  # Returns a default header if no configuration is provided, or a
23
22
  # header name and value based on the config.
24
- def make_header(config = nil)
23
+ def make_header(config = nil, user_agent = nil)
24
+ return if config == OPT_OUT
25
25
  config ||= DEFAULT_VALUE
26
26
  [HEADER_NAME, Array(config).join(", ")]
27
27
  end
@@ -8,14 +8,14 @@ module SecureHeaders
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
- CONFIG_KEY = :hsts
12
11
 
13
12
  class << self
14
13
  # Public: generate an hsts header name, value pair.
15
14
  #
16
15
  # Returns a default header if no configuration is provided, or a
17
16
  # header name and value based on the config.
18
- def make_header(config = nil)
17
+ def make_header(config = nil, user_agent = nil)
18
+ return if config == OPT_OUT
19
19
  [HEADER_NAME, config || DEFAULT_VALUE]
20
20
  end
21
21
 
@@ -5,14 +5,14 @@ module SecureHeaders
5
5
  class XContentTypeOptions
6
6
  HEADER_NAME = "X-Content-Type-Options".freeze
7
7
  DEFAULT_VALUE = "nosniff"
8
- CONFIG_KEY = :x_content_type_options
9
8
 
10
9
  class << self
11
10
  # Public: generate an X-Content-Type-Options header.
12
11
  #
13
12
  # Returns a default header if no configuration is provided, or a
14
13
  # header name and value based on the config.
15
- def make_header(config = nil)
14
+ def make_header(config = nil, user_agent = nil)
15
+ return if config == OPT_OUT
16
16
  [HEADER_NAME, config || DEFAULT_VALUE]
17
17
  end
18
18
 
@@ -4,14 +4,14 @@ module SecureHeaders
4
4
  class XDownloadOptions
5
5
  HEADER_NAME = "X-Download-Options".freeze
6
6
  DEFAULT_VALUE = "noopen"
7
- CONFIG_KEY = :x_download_options
8
7
 
9
8
  class << self
10
9
  # Public: generate an X-Download-Options header.
11
10
  #
12
11
  # Returns a default header if no configuration is provided, or a
13
12
  # header name and value based on the config.
14
- def make_header(config = nil)
13
+ def make_header(config = nil, user_agent = nil)
14
+ return if config == OPT_OUT
15
15
  [HEADER_NAME, config || DEFAULT_VALUE]
16
16
  end
17
17
 
@@ -3,7 +3,6 @@ module SecureHeaders
3
3
  class XFOConfigError < StandardError; end
4
4
  class XFrameOptions
5
5
  HEADER_NAME = "X-Frame-Options".freeze
6
- CONFIG_KEY = :x_frame_options
7
6
  SAMEORIGIN = "sameorigin"
8
7
  DENY = "deny"
9
8
  ALLOW_FROM = "allow-from"
@@ -16,7 +15,7 @@ module SecureHeaders
16
15
  #
17
16
  # Returns a default header if no configuration is provided, or a
18
17
  # header name and value based on the config.
19
- def make_header(config = nil)
18
+ def make_header(config = nil, user_agent = nil)
20
19
  return if config == OPT_OUT
21
20
  [HEADER_NAME, config || DEFAULT_VALUE]
22
21
  end
@@ -5,14 +5,14 @@ module SecureHeaders
5
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
- CONFIG_KEY = :x_permitted_cross_domain_policies
9
8
 
10
9
  class << self
11
10
  # Public: generate an X-Permitted-Cross-Domain-Policies header.
12
11
  #
13
12
  # Returns a default header if no configuration is provided, or a
14
13
  # header name and value based on the config.
15
- def make_header(config = nil)
14
+ def make_header(config = nil, user_agent = nil)
15
+ return if config == OPT_OUT
16
16
  [HEADER_NAME, config || DEFAULT_VALUE]
17
17
  end
18
18
 
@@ -4,15 +4,15 @@ module SecureHeaders
4
4
  class XXssProtection
5
5
  HEADER_NAME = "X-XSS-Protection".freeze
6
6
  DEFAULT_VALUE = "1; mode=block"
7
- VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/i
8
- CONFIG_KEY = :x_xss_protection
7
+ VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/
9
8
 
10
9
  class << self
11
10
  # Public: generate an X-Xss-Protection header.
12
11
  #
13
12
  # Returns a default header if no configuration is provided, or a
14
13
  # header name and value based on the config.
15
- def make_header(config = nil)
14
+ def make_header(config = nil, user_agent = nil)
15
+ return if config == OPT_OUT
16
16
  [HEADER_NAME, config || DEFAULT_VALUE]
17
17
  end
18
18
 
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
  Gem::Specification.new do |gem|
4
4
  gem.name = "secure_headers"
5
- gem.version = "5.2.0"
5
+ gem.version = "6.0.0.alpha01"
6
6
  gem.authors = ["Neil Matatall"]
7
7
  gem.email = ["neil.matatall@gmail.com"]
8
8
  gem.description = "Manages application of security headers with many safe defaults."
@@ -5,88 +5,33 @@ module SecureHeaders
5
5
  describe Configuration do
6
6
  before(:each) do
7
7
  reset_config
8
- Configuration.default
9
8
  end
10
9
 
11
10
  it "has a default config" do
12
- expect(Configuration.get(Configuration::DEFAULT_CONFIG, internal: true)).to_not be_nil
13
- end
14
-
15
- it "warns when using deprecated internal-ish #get API" do
16
- expect(Kernel).to receive(:warn).once.with(/`#get` is deprecated/)
17
- Configuration.get(Configuration::DEFAULT_CONFIG)
18
- end
19
-
20
- it "has an 'noop' config" do
21
- expect(Configuration.get(Configuration::NOOP_CONFIGURATION, internal: true)).to_not be_nil
22
- end
23
-
24
- it "precomputes headers upon creation" do
25
- default_config = Configuration.get(Configuration::DEFAULT_CONFIG, internal: true)
26
- header_hash = default_config.cached_headers.each_with_object({}) do |(key, value), hash|
27
- header_name, header_value = if key == :csp
28
- value["Chrome"]
29
- else
30
- value
31
- end
32
-
33
- hash[header_name] = header_value
34
- end
35
- expect_default_values(header_hash)
11
+ expect(Configuration.default).to_not be_nil
36
12
  end
37
13
 
38
- it "copies config values when duping" do
39
- Configuration.override(:test_override, Configuration::NOOP_CONFIGURATION) do
40
- # do nothing, just copy it
41
- end
42
-
43
- config = Configuration.get(:test_override, internal: true)
44
- noop = Configuration.get(Configuration::NOOP_CONFIGURATION, internal: true)
45
- [:csp, :csp_report_only, :cookies].each do |key|
46
- expect(config.send(key)).to eq(noop.send(key))
47
- end
14
+ it "has an 'noop' override" do
15
+ Configuration.default
16
+ expect(Configuration.overrides(Configuration::NOOP_OVERRIDE)).to_not be_nil
48
17
  end
49
18
 
50
- it "regenerates cached headers when building an override" do
51
- Configuration.override(:test_override) do |config|
52
- config.x_content_type_options = OPT_OUT
19
+ it "dup results in a copy of the default config" do
20
+ Configuration.default
21
+ original_configuration = Configuration.send(:default_config)
22
+ configuration = Configuration.dup
23
+ expect(original_configuration).not_to be(configuration)
24
+ Configuration::CONFIG_ATTRIBUTES.each do |attr|
25
+ expect(original_configuration.send(attr)).to eq(configuration.send(attr))
53
26
  end
54
-
55
- expect(Configuration.get(Configuration::DEFAULT_CONFIG, internal: true).cached_headers).to_not eq(Configuration.get(:test_override, internal: true).cached_headers)
56
27
  end
57
28
 
58
- it "stores an override of the global config" do
29
+ it "stores an override" do
59
30
  Configuration.override(:test_override) do |config|
60
31
  config.x_frame_options = "DENY"
61
32
  end
62
33
 
63
- expect(Configuration.get(:test_override, internal: true)).to_not be_nil
64
- end
65
-
66
- it "deep dup's config values when overriding so the original cannot be modified" do
67
- Configuration.override(:override) do |config|
68
- config.csp[:default_src] << "'self'"
69
- end
70
-
71
- default = Configuration.get(Configuration::DEFAULT_CONFIG, internal: true)
72
- override = Configuration.get(:override, internal: true)
73
-
74
- expect(override.csp.directive_value(:default_src)).not_to be(default.csp.directive_value(:default_src))
75
- end
76
-
77
- it "allows you to override an override" do
78
- Configuration.override(:override) do |config|
79
- config.csp = { default_src: %w('self'), script_src: %w('self')}
80
- end
81
-
82
- Configuration.override(:second_override, :override) do |config|
83
- config.csp = config.csp.merge(script_src: %w(example.org))
84
- end
85
-
86
- original_override = Configuration.get(:override, internal: true)
87
- expect(original_override.csp.to_h).to eq(default_src: %w('self'), script_src: %w('self'))
88
- override_config = Configuration.get(:second_override, internal: true)
89
- expect(override_config.csp.to_h).to eq(default_src: %w('self'), script_src: %w('self' example.org))
34
+ expect(Configuration.overrides(:test_override)).to_not be_nil
90
35
  end
91
36
 
92
37
  it "deprecates the secure_cookies configuration" do
@@ -106,7 +51,7 @@ module SecureHeaders
106
51
  config.cookies = OPT_OUT
107
52
  end
108
53
 
109
- config = Configuration.get(Configuration::DEFAULT_CONFIG, internal: true)
54
+ config = Configuration.dup
110
55
  expect(config.cookies).to eq(OPT_OUT)
111
56
  end
112
57
 
@@ -115,7 +60,7 @@ module SecureHeaders
115
60
  config.cookies = {httponly: true, secure: true, samesite: {lax: false}}
116
61
  end
117
62
 
118
- config = Configuration.get(Configuration::DEFAULT_CONFIG, internal: true)
63
+ config = Configuration.dup
119
64
  expect(config.cookies).to eq({httponly: true, secure: true, samesite: {lax: false}})
120
65
  end
121
66
  end