secure_headers 3.6.0 → 4.0.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +3 -0
  4. data/.ruby-version +1 -1
  5. data/.travis.yml +8 -4
  6. data/CHANGELOG.md +38 -1
  7. data/CODE_OF_CONDUCT.md +46 -0
  8. data/CONTRIBUTING.md +41 -0
  9. data/Gemfile +7 -4
  10. data/Guardfile +1 -0
  11. data/LICENSE +4 -199
  12. data/README.md +18 -20
  13. data/Rakefile +22 -18
  14. data/docs/cookies.md +16 -2
  15. data/docs/hashes.md +1 -1
  16. data/docs/per_action_configuration.md +28 -0
  17. data/lib/secure_headers/configuration.rb +30 -17
  18. data/lib/secure_headers/hash_helper.rb +2 -1
  19. data/lib/secure_headers/headers/clear_site_data.rb +16 -12
  20. data/lib/secure_headers/headers/content_security_policy.rb +54 -22
  21. data/lib/secure_headers/headers/content_security_policy_config.rb +46 -12
  22. data/lib/secure_headers/headers/cookie.rb +21 -3
  23. data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
  24. data/lib/secure_headers/headers/policy_management.rb +122 -67
  25. data/lib/secure_headers/headers/public_key_pins.rb +5 -4
  26. data/lib/secure_headers/headers/referrer_policy.rb +2 -1
  27. data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
  28. data/lib/secure_headers/headers/x_content_type_options.rb +2 -1
  29. data/lib/secure_headers/headers/x_download_options.rb +3 -2
  30. data/lib/secure_headers/headers/x_frame_options.rb +2 -1
  31. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
  32. data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
  33. data/lib/secure_headers/middleware.rb +10 -9
  34. data/lib/secure_headers/railtie.rb +7 -6
  35. data/lib/secure_headers/utils/cookies_config.rb +13 -12
  36. data/lib/secure_headers/view_helper.rb +4 -3
  37. data/lib/secure_headers.rb +7 -3
  38. data/lib/tasks/tasks.rake +2 -1
  39. data/secure_headers.gemspec +13 -3
  40. data/spec/lib/secure_headers/configuration_spec.rb +9 -8
  41. data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +11 -27
  42. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +47 -26
  43. data/spec/lib/secure_headers/headers/cookie_spec.rb +38 -20
  44. data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
  45. data/spec/lib/secure_headers/headers/policy_management_spec.rb +57 -10
  46. data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
  47. data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +4 -3
  48. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +5 -4
  49. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
  50. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
  51. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
  52. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
  53. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
  54. data/spec/lib/secure_headers/middleware_spec.rb +29 -21
  55. data/spec/lib/secure_headers/view_helpers_spec.rb +5 -5
  56. data/spec/lib/secure_headers_spec.rb +106 -107
  57. data/spec/spec_helper.rb +10 -22
  58. data/upgrading-to-4-0.md +55 -0
  59. metadata +19 -7
@@ -1,4 +1,5 @@
1
- require 'yaml'
1
+ # frozen_string_literal: true
2
+ require "yaml"
2
3
 
3
4
  module SecureHeaders
4
5
  class Configuration
@@ -31,7 +32,7 @@ module SecureHeaders
31
32
  raise NotYetConfiguredError, "#{base} policy not yet supplied"
32
33
  end
33
34
  override = @configurations[base].dup
34
- override.instance_eval &block if block_given?
35
+ override.instance_eval(&block) if block_given?
35
36
  add_configuration(name, override)
36
37
  end
37
38
 
@@ -116,23 +117,41 @@ module SecureHeaders
116
117
 
117
118
  attr_writer :hsts, :x_frame_options, :x_content_type_options,
118
119
  :x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
119
- :referrer_policy, :clear_site_data
120
+ :referrer_policy, :clear_site_data, :expect_certificate_transparency
120
121
 
121
122
  attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
122
123
 
124
+ @script_hashes = nil
125
+ @style_hashes = nil
126
+
123
127
  HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
124
- if File.exists?(HASH_CONFIG_FILE)
128
+ if File.exist?(HASH_CONFIG_FILE)
125
129
  config = YAML.safe_load(File.open(HASH_CONFIG_FILE))
126
130
  @script_hashes = config["scripts"]
127
131
  @style_hashes = config["styles"]
128
132
  end
129
133
 
130
134
  def initialize(&block)
135
+ @cookies = nil
136
+ @clear_site_data = nil
137
+ @csp = nil
138
+ @csp_report_only = nil
139
+ @hpkp_report_host = nil
140
+ @hpkp = nil
141
+ @hsts = nil
142
+ @x_content_type_options = nil
143
+ @x_download_options = nil
144
+ @x_frame_options = nil
145
+ @x_permitted_cross_domain_policies = nil
146
+ @x_xss_protection = nil
147
+ @expect_certificate_transparency = nil
148
+
131
149
  self.hpkp = OPT_OUT
132
150
  self.referrer_policy = OPT_OUT
133
151
  self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
134
152
  self.csp_report_only = OPT_OUT
135
- instance_eval &block if block_given?
153
+
154
+ instance_eval(&block) if block_given?
136
155
  end
137
156
 
138
157
  # Public: copy everything but the cached headers
@@ -151,6 +170,7 @@ module SecureHeaders
151
170
  copy.x_download_options = @x_download_options
152
171
  copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies
153
172
  copy.clear_site_data = @clear_site_data
173
+ copy.expect_certificate_transparency = @expect_certificate_transparency
154
174
  copy.referrer_policy = @referrer_policy
155
175
  copy.hpkp = @hpkp
156
176
  copy.hpkp_report_host = @hpkp_report_host
@@ -183,13 +203,13 @@ module SecureHeaders
183
203
  XDownloadOptions.validate_config!(@x_download_options)
184
204
  XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
185
205
  ClearSiteData.validate_config!(@clear_site_data)
206
+ ExpectCertificateTransparency.validate_config!(@expect_certificate_transparency)
186
207
  PublicKeyPins.validate_config!(@hpkp)
187
208
  Cookie.validate_config!(@cookies)
188
209
  end
189
210
 
190
211
  def secure_cookies=(secure_cookies)
191
- Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#secure_cookies=` is deprecated. Please use `#cookies=` to configure secure cookies instead."
192
- @cookies = (@cookies || {}).merge(secure: secure_cookies)
212
+ raise ArgumentError, "#{Kernel.caller.first}: `#secure_cookies=` is no longer supported. Please use `#cookies=` to configure secure cookies instead."
193
213
  end
194
214
 
195
215
  def csp=(new_csp)
@@ -197,10 +217,8 @@ module SecureHeaders
197
217
  @csp = new_csp.dup
198
218
  else
199
219
  if new_csp[:report_only]
200
- # Deprecated configuration implies that CSPRO should be set, CSP should not - so opt out
201
- Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp=` was supplied a config with report_only: true. Use #csp_report_only="
202
- @csp = OPT_OUT
203
- self.csp_report_only = new_csp
220
+ # invalid configuration implies that CSPRO should be set, CSP should not - so opt out
221
+ raise ArgumentError, "#{Kernel.caller.first}: `#csp=` was supplied a config with report_only: true. Use #csp_report_only="
204
222
  else
205
223
  @csp = ContentSecurityPolicyConfig.new(new_csp)
206
224
  end
@@ -227,11 +245,6 @@ module SecureHeaders
227
245
  end
228
246
  end
229
247
  end
230
-
231
- if !@csp_report_only.opt_out? && @csp.to_h == ContentSecurityPolicyConfig::DEFAULT
232
- Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#csp_report_only=` was configured before `#csp=`. It is assumed you intended to opt out of `#csp=` so be sure to add `config.csp = SecureHeaders::OPT_OUT` to your config. Ensure that #csp_report_only is configured after #csp="
233
- @csp = OPT_OUT
234
- end
235
248
  end
236
249
 
237
250
  protected
@@ -262,7 +275,7 @@ module SecureHeaders
262
275
  end
263
276
  end
264
277
 
265
- # Public: Precompute the header names and values for this configuraiton.
278
+ # Public: Precompute the header names and values for this configuration.
266
279
  # Ensures that headers generated at configure time, not on demand.
267
280
  #
268
281
  # Returns the cached headers
@@ -1,4 +1,5 @@
1
- require 'base64'
1
+ # frozen_string_literal: true
2
+ require "base64"
2
3
 
3
4
  module SecureHeaders
4
5
  module HashHelper
@@ -1,15 +1,15 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  class ClearSiteDataConfigError < StandardError; end
3
4
  class ClearSiteData
4
5
  HEADER_NAME = "Clear-Site-Data".freeze
5
- TYPES = "types".freeze
6
6
 
7
7
  # Valid `types`
8
8
  CACHE = "cache".freeze
9
9
  COOKIES = "cookies".freeze
10
10
  STORAGE = "storage".freeze
11
- EXECTION_CONTEXTS = "executionContexts".freeze
12
- ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECTION_CONTEXTS]
11
+ EXECUTION_CONTEXTS = "executionContexts".freeze
12
+ ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
13
13
 
14
14
  CONFIG_KEY = :clear_site_data
15
15
 
@@ -17,14 +17,14 @@ module SecureHeaders
17
17
  # Public: make an Clear-Site-Data header name, value pair
18
18
  #
19
19
  # Returns nil if not configured, returns header name and value if configured.
20
- def make_header(config=nil)
20
+ def make_header(config = nil)
21
21
  case config
22
22
  when nil, OPT_OUT, []
23
23
  # noop
24
24
  when Array
25
- [HEADER_NAME, JSON.dump(TYPES => config)]
25
+ [HEADER_NAME, make_header_value(config)]
26
26
  when true
27
- [HEADER_NAME, JSON.dump(TYPES => ALL_TYPES)]
27
+ [HEADER_NAME, make_header_value(ALL_TYPES)]
28
28
  end
29
29
  end
30
30
 
@@ -36,16 +36,20 @@ module SecureHeaders
36
36
  unless config.all? { |t| t.is_a?(String) }
37
37
  raise ClearSiteDataConfigError.new("types must be Strings")
38
38
  end
39
-
40
- begin
41
- JSON.dump(config)
42
- rescue JSON::GeneratorError, Encoding::UndefinedConversionError
43
- raise ClearSiteDataConfigError.new("types must serializable by JSON")
44
- end
45
39
  else
46
40
  raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
47
41
  end
48
42
  end
43
+
44
+ # Public: Transform a Clear-Site-Data config (an Array of Strings) into a
45
+ # String that can be used as the value for the Clear-Site-Data header.
46
+ #
47
+ # types - An Array of String of types of data to clear.
48
+ #
49
+ # Returns a String of quoted values that are comma separated.
50
+ def make_header_value(types)
51
+ types.map { |t| "\"#{t}\""}.join(", ")
52
+ end
49
53
  end
50
54
  end
51
55
  end
@@ -1,6 +1,7 @@
1
- require_relative 'policy_management'
2
- require_relative 'content_security_policy_config'
3
- require 'useragent'
1
+ # frozen_string_literal: true
2
+ require_relative "policy_management"
3
+ require_relative "content_security_policy_config"
4
+ require "useragent"
4
5
 
5
6
  module SecureHeaders
6
7
  class ContentSecurityPolicy
@@ -54,20 +55,12 @@ module SecureHeaders
54
55
 
55
56
  private
56
57
 
57
- # frame-src is deprecated, child-src is being implemented. They are
58
- # very similar and in most cases, the same value can be used for both.
59
58
  def normalize_child_frame_src
60
59
  if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
61
- Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers.")
62
- elsif @config.frame_src
63
- Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config.frame_src}.")
60
+ raise ArgumentError, "#{Kernel.caller.first}: both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers."
64
61
  end
65
62
 
66
- if supported_directives.include?(:child_src)
67
- @config.child_src || @config.frame_src
68
- else
69
- @config.frame_src || @config.child_src
70
- end
63
+ @config.frame_src || @config.child_src
71
64
  end
72
65
 
73
66
  # Private: converts the config object into a string representing a policy.
@@ -82,20 +75,55 @@ module SecureHeaders
82
75
  case DIRECTIVE_VALUE_TYPES[directive_name]
83
76
  when :boolean
84
77
  symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
85
- when :string
86
- [symbol_to_hyphen_case(directive_name), @config.directive_value(directive_name)].join(" ")
87
- else
88
- build_directive(directive_name)
78
+ when :sandbox_list
79
+ build_sandbox_list_directive(directive_name)
80
+ when :media_type_list
81
+ build_media_type_list_directive(directive_name)
82
+ when :source_list
83
+ build_source_list_directive(directive_name)
89
84
  end
90
85
  end.compact.join("; ")
91
86
  end
92
87
 
88
+ def build_sandbox_list_directive(directive)
89
+ return unless sandbox_list = @config.directive_value(directive)
90
+ max_strict_policy = case sandbox_list
91
+ when Array
92
+ sandbox_list.empty?
93
+ when true
94
+ true
95
+ else
96
+ false
97
+ end
98
+
99
+ # A maximally strict sandbox policy is just the `sandbox` directive,
100
+ # whith no configuraiton values.
101
+ if max_strict_policy
102
+ symbol_to_hyphen_case(directive)
103
+ elsif sandbox_list && sandbox_list.any?
104
+ [
105
+ symbol_to_hyphen_case(directive),
106
+ sandbox_list.uniq
107
+ ].join(" ")
108
+ end
109
+ end
110
+
111
+ def build_media_type_list_directive(directive)
112
+ return unless media_type_list = @config.directive_value(directive)
113
+ if media_type_list && media_type_list.any?
114
+ [
115
+ symbol_to_hyphen_case(directive),
116
+ media_type_list.uniq
117
+ ].join(" ")
118
+ end
119
+ end
120
+
93
121
  # Private: builds a string that represents one directive in a minified form.
94
122
  #
95
123
  # directive_name - a symbol representing the various ALL_DIRECTIVES
96
124
  #
97
125
  # Returns a string representing a directive.
98
- def build_directive(directive)
126
+ def build_source_list_directive(directive)
99
127
  source_list = case directive
100
128
  when :child_src
101
129
  if supported_directives.include?(:child_src)
@@ -108,9 +136,11 @@ module SecureHeaders
108
136
  else
109
137
  @config.directive_value(directive)
110
138
  end
111
- return unless source_list && source_list.any?
112
- normalized_source_list = minify_source_list(directive, source_list)
113
- [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
139
+
140
+ if source_list != OPT_OUT && source_list && source_list.any?
141
+ normalized_source_list = minify_source_list(directive, source_list)
142
+ [symbol_to_hyphen_case(directive), normalized_source_list].join(" ")
143
+ end
114
144
  end
115
145
 
116
146
  # If a directive contains *, all other values are omitted.
@@ -216,6 +246,8 @@ module SecureHeaders
216
246
  @supported_directives ||= if VARIATIONS[@parsed_ua.browser]
217
247
  if @parsed_ua.browser == "Firefox" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_46)
218
248
  VARIATIONS["FirefoxTransitional"]
249
+ elsif @parsed_ua.browser == "Safari" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_10)
250
+ VARIATIONS["SafariTransitional"]
219
251
  else
220
252
  VARIATIONS[@parsed_ua.browser]
221
253
  end
@@ -229,7 +261,7 @@ module SecureHeaders
229
261
  end
230
262
 
231
263
  def symbol_to_hyphen_case(sym)
232
- sym.to_s.tr('_', '-')
264
+ sym.to_s.tr("_", "-")
233
265
  end
234
266
  end
235
267
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SecureHeaders
2
3
  module DynamicConfig
3
4
  def self.included(base)
@@ -6,12 +7,7 @@ module SecureHeaders
6
7
  base.attrs.each do |attr|
7
8
  base.send(:define_method, "#{attr}=") do |value|
8
9
  if self.class.attrs.include?(attr)
9
- value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
10
- prev_value = self.instance_variable_get("@#{attr}")
11
- self.instance_variable_set("@#{attr}", value)
12
- if prev_value != self.instance_variable_get("@#{attr}")
13
- @modified = true
14
- end
10
+ write_attribute(attr, value)
15
11
  else
16
12
  raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}"
17
13
  end
@@ -20,6 +16,30 @@ module SecureHeaders
20
16
  end
21
17
 
22
18
  def initialize(hash)
19
+ @base_uri = nil
20
+ @block_all_mixed_content = nil
21
+ @child_src = nil
22
+ @connect_src = nil
23
+ @default_src = nil
24
+ @font_src = nil
25
+ @form_action = nil
26
+ @frame_ancestors = nil
27
+ @frame_src = nil
28
+ @img_src = nil
29
+ @manifest_src = nil
30
+ @media_src = nil
31
+ @object_src = nil
32
+ @plugin_types = nil
33
+ @preserve_schemes = nil
34
+ @report_only = nil
35
+ @report_uri = nil
36
+ @sandbox = nil
37
+ @script_nonce = nil
38
+ @script_src = nil
39
+ @style_nonce = nil
40
+ @style_src = nil
41
+ @upgrade_insecure_requests = nil
42
+
23
43
  from_hash(hash)
24
44
  @modified = false
25
45
  end
@@ -52,8 +72,9 @@ module SecureHeaders
52
72
 
53
73
  def to_h
54
74
  self.class.attrs.each_with_object({}) do |key, hash|
55
- hash[key] = self.send(key)
56
- end.reject { |_, v| v.nil? }
75
+ value = self.send(key)
76
+ hash[key] = value unless value.nil?
77
+ end
57
78
  end
58
79
 
59
80
  def dup
@@ -73,14 +94,26 @@ module SecureHeaders
73
94
 
74
95
  private
75
96
  def from_hash(hash)
76
- hash.keys.reject { |k| hash[k].nil? }.map do |k|
97
+ hash.each_pair do |k, v|
98
+ next if v.nil?
99
+
77
100
  if self.class.attrs.include?(k)
78
- self.send("#{k}=", hash[k])
101
+ write_attribute(k, v)
79
102
  else
80
- raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{hash[k]}"
103
+ raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{v}"
81
104
  end
82
105
  end
83
106
  end
107
+
108
+ def write_attribute(attr, value)
109
+ value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
110
+ attr_variable = "@#{attr}"
111
+ prev_value = self.instance_variable_get(attr_variable)
112
+ self.instance_variable_set(attr_variable, value)
113
+ if prev_value != value
114
+ @modified = true
115
+ end
116
+ end
84
117
  end
85
118
 
86
119
  class ContentSecurityPolicyConfigError < StandardError; end
@@ -88,8 +121,9 @@ module SecureHeaders
88
121
  CONFIG_KEY = :csp
89
122
  HEADER_NAME = "Content-Security-Policy".freeze
90
123
 
124
+ ATTRS = PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
91
125
  def self.attrs
92
- PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
126
+ ATTRS
93
127
  end
94
128
 
95
129
  include DynamicConfig
@@ -1,5 +1,7 @@
1
- require 'cgi'
2
- require 'secure_headers/utils/cookies_config'
1
+ # frozen_string_literal: true
2
+ require "cgi"
3
+ require "secure_headers/utils/cookies_config"
4
+
3
5
 
4
6
  module SecureHeaders
5
7
  class CookiesConfigError < StandardError; end
@@ -13,8 +15,18 @@ module SecureHeaders
13
15
 
14
16
  attr_reader :raw_cookie, :config
15
17
 
18
+ COOKIE_DEFAULTS = {
19
+ httponly: true,
20
+ secure: true,
21
+ samesite: { lax: true },
22
+ }.freeze
23
+
16
24
  def initialize(cookie, config)
17
25
  @raw_cookie = cookie
26
+ unless config == OPT_OUT
27
+ config ||= {}
28
+ config = COOKIE_DEFAULTS.merge(config)
29
+ end
18
30
  @config = config
19
31
  @attributes = {
20
32
  httponly: nil,
@@ -56,6 +68,7 @@ module SecureHeaders
56
68
  end
57
69
 
58
70
  def flag_cookie?(attribute)
71
+ return false if config == OPT_OUT
59
72
  case config[attribute]
60
73
  when TrueClass
61
74
  true
@@ -85,6 +98,7 @@ module SecureHeaders
85
98
  end
86
99
 
87
100
  def flag_samesite?
101
+ return false if config == OPT_OUT || config[:samesite] == OPT_OUT
88
102
  flag_samesite_lax? || flag_samesite_strict?
89
103
  end
90
104
 
@@ -99,6 +113,10 @@ module SecureHeaders
99
113
  def flag_samesite_enforcement?(mode)
100
114
  return unless config[:samesite]
101
115
 
116
+ if config[:samesite].is_a?(TrueClass) && mode == :lax
117
+ return true
118
+ end
119
+
102
120
  case config[:samesite][mode]
103
121
  when Hash
104
122
  conditionally_flag?(config[:samesite][mode])
@@ -113,7 +131,7 @@ module SecureHeaders
113
131
  return unless cookie
114
132
 
115
133
  cookie.split(/[;,]\s?/).each do |pairs|
116
- name, values = pairs.split('=',2)
134
+ name, values = pairs.split("=", 2)
117
135
  name = CGI.unescape(name)
118
136
 
119
137
  attribute = name.downcase.to_sym
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ module SecureHeaders
3
+ class ExpectCertificateTransparencyConfigError < StandardError; end
4
+
5
+ class ExpectCertificateTransparency
6
+ HEADER_NAME = "Expect-CT".freeze
7
+ CONFIG_KEY = :expect_certificate_transparency
8
+ INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
9
+ INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze
10
+ REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
11
+ INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
12
+
13
+ class << self
14
+ # Public: Generate a Expect-CT header.
15
+ #
16
+ # Returns nil if not configured, returns header name and value if
17
+ # configured.
18
+ def make_header(config)
19
+ return if config.nil?
20
+
21
+ header = new(config)
22
+ [HEADER_NAME, header.value]
23
+ end
24
+
25
+ def validate_config!(config)
26
+ return if config.nil? || config == OPT_OUT
27
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
28
+
29
+ unless [true, false, nil].include?(config[:enforce])
30
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
31
+ end
32
+
33
+ if !config[:max_age]
34
+ raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
35
+ elsif config[:max_age].to_s !~ /\A\d+\z/
36
+ raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
37
+ end
38
+ end
39
+ end
40
+
41
+ def initialize(config)
42
+ @enforced = config.fetch(:enforce, nil)
43
+ @max_age = config.fetch(:max_age, nil)
44
+ @report_uri = config.fetch(:report_uri, nil)
45
+ end
46
+
47
+ def value
48
+ [
49
+ enforced_directive,
50
+ max_age_directive,
51
+ report_uri_directive
52
+ ].compact.join("; ").strip
53
+ end
54
+
55
+ def enforced_directive
56
+ # Unfortunately `if @enforced` isn't enough here in case someone
57
+ # passes in a random string so let's be specific with it to prevent
58
+ # accidental enforcement.
59
+ "enforce" if @enforced == true
60
+ end
61
+
62
+ def max_age_directive
63
+ "max-age=#{@max_age}" if @max_age
64
+ end
65
+
66
+ def report_uri_directive
67
+ "report-uri=\"#{@report_uri}\"" if @report_uri
68
+ end
69
+ end
70
+ end