secure_headers 2.5.3 → 3.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


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

Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/.travis.yml +2 -1
  4. data/Gemfile +9 -16
  5. data/README.md +154 -331
  6. data/Rakefile +2 -36
  7. data/lib/secure_headers/configuration.rb +189 -0
  8. data/lib/secure_headers/headers/content_security_policy.rb +341 -254
  9. data/lib/secure_headers/headers/public_key_pins.rb +43 -58
  10. data/lib/secure_headers/headers/strict_transport_security.rb +21 -49
  11. data/lib/secure_headers/headers/x_content_type_options.rb +18 -33
  12. data/lib/secure_headers/headers/x_download_options.rb +18 -33
  13. data/lib/secure_headers/headers/x_frame_options.rb +24 -34
  14. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +19 -34
  15. data/lib/secure_headers/headers/x_xss_protection.rb +17 -48
  16. data/lib/secure_headers/middleware.rb +15 -0
  17. data/lib/secure_headers/padrino.rb +1 -2
  18. data/lib/secure_headers/railtie.rb +9 -6
  19. data/lib/secure_headers/view_helper.rb +27 -43
  20. data/lib/secure_headers.rb +254 -61
  21. data/secure_headers.gemspec +7 -12
  22. data/spec/lib/secure_headers/configuration_spec.rb +80 -0
  23. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +111 -276
  24. data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +17 -17
  25. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +11 -43
  26. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +11 -18
  27. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +13 -17
  28. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +15 -17
  29. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +22 -39
  30. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +20 -30
  31. data/spec/lib/secure_headers/middleware_spec.rb +40 -0
  32. data/spec/lib/secure_headers_spec.rb +201 -339
  33. data/spec/spec_helper.rb +30 -30
  34. data/upgrading-to-3-0.md +35 -0
  35. metadata +14 -100
  36. data/fixtures/rails_3_2_22/.rspec +0 -1
  37. data/fixtures/rails_3_2_22/Gemfile +0 -6
  38. data/fixtures/rails_3_2_22/README.rdoc +0 -261
  39. data/fixtures/rails_3_2_22/Rakefile +0 -7
  40. data/fixtures/rails_3_2_22/app/controllers/application_controller.rb +0 -4
  41. data/fixtures/rails_3_2_22/app/controllers/other_things_controller.rb +0 -5
  42. data/fixtures/rails_3_2_22/app/controllers/things_controller.rb +0 -5
  43. data/fixtures/rails_3_2_22/app/models/.gitkeep +0 -0
  44. data/fixtures/rails_3_2_22/app/views/layouts/application.html.erb +0 -11
  45. data/fixtures/rails_3_2_22/app/views/other_things/index.html.erb +0 -2
  46. data/fixtures/rails_3_2_22/app/views/things/index.html.erb +0 -1
  47. data/fixtures/rails_3_2_22/config/application.rb +0 -14
  48. data/fixtures/rails_3_2_22/config/boot.rb +0 -6
  49. data/fixtures/rails_3_2_22/config/environment.rb +0 -5
  50. data/fixtures/rails_3_2_22/config/environments/test.rb +0 -37
  51. data/fixtures/rails_3_2_22/config/initializers/secure_headers.rb +0 -16
  52. data/fixtures/rails_3_2_22/config/routes.rb +0 -4
  53. data/fixtures/rails_3_2_22/config/script_hashes.yml +0 -5
  54. data/fixtures/rails_3_2_22/config.ru +0 -7
  55. data/fixtures/rails_3_2_22/lib/assets/.gitkeep +0 -0
  56. data/fixtures/rails_3_2_22/lib/tasks/.gitkeep +0 -0
  57. data/fixtures/rails_3_2_22/log/.gitkeep +0 -0
  58. data/fixtures/rails_3_2_22/spec/controllers/other_things_controller_spec.rb +0 -83
  59. data/fixtures/rails_3_2_22/spec/controllers/things_controller_spec.rb +0 -54
  60. data/fixtures/rails_3_2_22/spec/spec_helper.rb +0 -15
  61. data/fixtures/rails_3_2_22/vendor/assets/javascripts/.gitkeep +0 -0
  62. data/fixtures/rails_3_2_22/vendor/assets/stylesheets/.gitkeep +0 -0
  63. data/fixtures/rails_3_2_22/vendor/plugins/.gitkeep +0 -0
  64. data/fixtures/rails_3_2_22_no_init/.rspec +0 -1
  65. data/fixtures/rails_3_2_22_no_init/Gemfile +0 -6
  66. data/fixtures/rails_3_2_22_no_init/README.rdoc +0 -261
  67. data/fixtures/rails_3_2_22_no_init/Rakefile +0 -7
  68. data/fixtures/rails_3_2_22_no_init/app/controllers/application_controller.rb +0 -4
  69. data/fixtures/rails_3_2_22_no_init/app/controllers/other_things_controller.rb +0 -20
  70. data/fixtures/rails_3_2_22_no_init/app/controllers/things_controller.rb +0 -5
  71. data/fixtures/rails_3_2_22_no_init/app/models/.gitkeep +0 -0
  72. data/fixtures/rails_3_2_22_no_init/app/views/layouts/application.html.erb +0 -12
  73. data/fixtures/rails_3_2_22_no_init/app/views/other_things/index.html.erb +0 -1
  74. data/fixtures/rails_3_2_22_no_init/app/views/things/index.html.erb +0 -0
  75. data/fixtures/rails_3_2_22_no_init/config/application.rb +0 -17
  76. data/fixtures/rails_3_2_22_no_init/config/boot.rb +0 -6
  77. data/fixtures/rails_3_2_22_no_init/config/environment.rb +0 -5
  78. data/fixtures/rails_3_2_22_no_init/config/environments/test.rb +0 -37
  79. data/fixtures/rails_3_2_22_no_init/config/routes.rb +0 -4
  80. data/fixtures/rails_3_2_22_no_init/config.ru +0 -4
  81. data/fixtures/rails_3_2_22_no_init/lib/assets/.gitkeep +0 -0
  82. data/fixtures/rails_3_2_22_no_init/lib/tasks/.gitkeep +0 -0
  83. data/fixtures/rails_3_2_22_no_init/log/.gitkeep +0 -0
  84. data/fixtures/rails_3_2_22_no_init/spec/controllers/other_things_controller_spec.rb +0 -56
  85. data/fixtures/rails_3_2_22_no_init/spec/controllers/things_controller_spec.rb +0 -54
  86. data/fixtures/rails_3_2_22_no_init/spec/spec_helper.rb +0 -5
  87. data/fixtures/rails_3_2_22_no_init/vendor/assets/javascripts/.gitkeep +0 -0
  88. data/fixtures/rails_3_2_22_no_init/vendor/assets/stylesheets/.gitkeep +0 -0
  89. data/fixtures/rails_3_2_22_no_init/vendor/plugins/.gitkeep +0 -0
  90. data/fixtures/rails_4_1_8/Gemfile +0 -5
  91. data/fixtures/rails_4_1_8/README.rdoc +0 -28
  92. data/fixtures/rails_4_1_8/Rakefile +0 -6
  93. data/fixtures/rails_4_1_8/app/controllers/application_controller.rb +0 -4
  94. data/fixtures/rails_4_1_8/app/controllers/concerns/.keep +0 -0
  95. data/fixtures/rails_4_1_8/app/controllers/other_things_controller.rb +0 -5
  96. data/fixtures/rails_4_1_8/app/controllers/things_controller.rb +0 -5
  97. data/fixtures/rails_4_1_8/app/models/.keep +0 -0
  98. data/fixtures/rails_4_1_8/app/models/concerns/.keep +0 -0
  99. data/fixtures/rails_4_1_8/app/views/layouts/application.html.erb +0 -11
  100. data/fixtures/rails_4_1_8/app/views/other_things/index.html.erb +0 -2
  101. data/fixtures/rails_4_1_8/app/views/things/index.html.erb +0 -1
  102. data/fixtures/rails_4_1_8/config/application.rb +0 -15
  103. data/fixtures/rails_4_1_8/config/boot.rb +0 -4
  104. data/fixtures/rails_4_1_8/config/environment.rb +0 -5
  105. data/fixtures/rails_4_1_8/config/environments/test.rb +0 -10
  106. data/fixtures/rails_4_1_8/config/initializers/secure_headers.rb +0 -16
  107. data/fixtures/rails_4_1_8/config/routes.rb +0 -4
  108. data/fixtures/rails_4_1_8/config/script_hashes.yml +0 -5
  109. data/fixtures/rails_4_1_8/config/secrets.yml +0 -22
  110. data/fixtures/rails_4_1_8/config.ru +0 -4
  111. data/fixtures/rails_4_1_8/lib/assets/.keep +0 -0
  112. data/fixtures/rails_4_1_8/lib/tasks/.keep +0 -0
  113. data/fixtures/rails_4_1_8/log/.keep +0 -0
  114. data/fixtures/rails_4_1_8/spec/controllers/other_things_controller_spec.rb +0 -83
  115. data/fixtures/rails_4_1_8/spec/controllers/things_controller_spec.rb +0 -59
  116. data/fixtures/rails_4_1_8/spec/spec_helper.rb +0 -15
  117. data/fixtures/rails_4_1_8/vendor/assets/javascripts/.keep +0 -0
  118. data/fixtures/rails_4_1_8/vendor/assets/stylesheets/.keep +0 -0
  119. data/lib/secure_headers/controller_extension.rb +0 -158
  120. data/lib/secure_headers/hash_helper.rb +0 -7
  121. data/lib/secure_headers/header.rb +0 -5
  122. data/lib/secure_headers/headers/content_security_policy/script_hash_middleware.rb +0 -22
  123. data/lib/secure_headers/version.rb +0 -3
  124. data/lib/tasks/tasks.rake +0 -48
  125. data/spec/lib/secure_headers/headers/content_security_policy/script_hash_middleware_spec.rb +0 -46
@@ -1,334 +1,421 @@
1
1
  require 'uri'
2
2
  require 'base64'
3
3
  require 'securerandom'
4
- require 'user_agent_parser'
5
4
  require 'json'
6
5
 
7
6
  module SecureHeaders
8
- class ContentSecurityPolicyBuildError < StandardError; end
9
- class ContentSecurityPolicy < Header
10
- module Constants
11
- DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https: about: javascript:; img-src data:"
12
- HEADER_NAME = "Content-Security-Policy"
13
- ENV_KEY = 'secure_headers.content_security_policy'
14
- USER_AGENT_PARSER = UserAgentParser::Parser.new
15
-
16
- DIRECTIVES_1_0 = [
17
- :default_src,
18
- :connect_src,
19
- :font_src,
20
- :frame_src,
21
- :img_src,
22
- :media_src,
23
- :object_src,
24
- :sandbox,
25
- :script_src,
26
- :style_src,
27
- :report_uri
28
- ].freeze
29
-
30
- DIRECTIVES_2_0 = [
31
- DIRECTIVES_1_0,
32
- :base_uri,
33
- :child_src,
34
- :form_action,
35
- :frame_ancestors,
36
- :plugin_types
37
- ].flatten.freeze
38
-
39
-
40
- # All the directives currently under consideration for CSP level 3.
41
- # https://w3c.github.io/webappsec/specs/CSP2/
42
- DIRECTIVES_3_0 = [
43
- DIRECTIVES_2_0,
44
- :manifest_src,
45
- :reflected_xss
46
- ].flatten.freeze
47
-
48
- # All the directives that are not currently in a formal spec, but have
49
- # been implemented somewhere.
50
- DIRECTIVES_DRAFT = [
51
- :block_all_mixed_content,
52
- ].freeze
53
-
54
- SAFARI_DIRECTIVES = DIRECTIVES_1_0
55
-
56
- FIREFOX_UNSUPPORTED_DIRECTIVES = [
57
- :block_all_mixed_content,
58
- :child_src,
59
- :plugin_types
60
- ].freeze
61
-
62
- FIREFOX_DIRECTIVES = (
63
- DIRECTIVES_2_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
64
- ).freeze
65
-
66
- CHROME_DIRECTIVES = (
67
- DIRECTIVES_2_0 + DIRECTIVES_DRAFT
68
- ).freeze
69
-
70
- ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
71
- CONFIG_KEY = :csp
72
- end
73
-
74
- include Constants
75
-
76
- attr_reader :ssl_request
77
- alias :ssl_request? :ssl_request
7
+ class ContentSecurityPolicyConfigError < StandardError; end
8
+ class ContentSecurityPolicy
9
+ MODERN_BROWSERS = %w(Chrome Opera Firefox)
10
+ DEFAULT_VALUE = "default-src https:".freeze
11
+ DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
12
+ HEADER_NAME = "Content-Security-Policy".freeze
13
+ REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze
14
+ HEADER_NAMES = [HEADER_NAME, REPORT_ONLY]
15
+ DATA_PROTOCOL = "data:".freeze
16
+ SELF = "'self'".freeze
17
+ NONE = "'none'".freeze
18
+ STAR = "*".freeze
19
+ UNSAFE_INLINE = "'unsafe-inline'".freeze
20
+ UNSAFE_EVAL = "'unsafe-eval'".freeze
21
+
22
+ # leftover deprecated values that will be in common use upon upgrading.
23
+ DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
24
+
25
+ DEFAULT_SRC = :default_src
26
+ CONNECT_SRC = :connect_src
27
+ FONT_SRC = :font_src
28
+ FRAME_SRC = :frame_src
29
+ IMG_SRC = :img_src
30
+ MEDIA_SRC = :media_src
31
+ OBJECT_SRC = :object_src
32
+ SANDBOX = :sandbox
33
+ SCRIPT_SRC = :script_src
34
+ STYLE_SRC = :style_src
35
+ REPORT_URI = :report_uri
36
+
37
+ DIRECTIVES_1_0 = [
38
+ DEFAULT_SRC,
39
+ CONNECT_SRC,
40
+ FONT_SRC,
41
+ FRAME_SRC,
42
+ IMG_SRC,
43
+ MEDIA_SRC,
44
+ OBJECT_SRC,
45
+ SANDBOX,
46
+ SCRIPT_SRC,
47
+ STYLE_SRC,
48
+ REPORT_URI
49
+ ].freeze
50
+
51
+ BASE_URI = :base_uri
52
+ CHILD_SRC = :child_src
53
+ FORM_ACTION = :form_action
54
+ FRAME_ANCESTORS = :frame_ancestors
55
+ PLUGIN_TYPES = :plugin_types
56
+
57
+ DIRECTIVES_2_0 = [
58
+ DIRECTIVES_1_0,
59
+ BASE_URI,
60
+ CHILD_SRC,
61
+ FORM_ACTION,
62
+ FRAME_ANCESTORS,
63
+ PLUGIN_TYPES
64
+ ].flatten.freeze
65
+
66
+ # All the directives currently under consideration for CSP level 3.
67
+ # https://w3c.github.io/webappsec/specs/CSP2/
68
+ MANIFEST_SRC = :manifest_src
69
+ REFLECTED_XSS = :reflected_xss
70
+ DIRECTIVES_3_0 = [
71
+ DIRECTIVES_2_0,
72
+ MANIFEST_SRC,
73
+ REFLECTED_XSS
74
+ ].flatten.freeze
75
+
76
+ # All the directives that are not currently in a formal spec, but have
77
+ # been implemented somewhere.
78
+ BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
79
+ DIRECTIVES_DRAFT = [
80
+ BLOCK_ALL_MIXED_CONTENT
81
+ ].freeze
82
+
83
+ SAFARI_DIRECTIVES = DIRECTIVES_1_0
84
+
85
+ FIREFOX_UNSUPPORTED_DIRECTIVES = [
86
+ BLOCK_ALL_MIXED_CONTENT,
87
+ CHILD_SRC,
88
+ PLUGIN_TYPES
89
+ ].freeze
90
+
91
+ FIREFOX_DIRECTIVES = (
92
+ DIRECTIVES_2_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
93
+ ).freeze
94
+
95
+ CHROME_DIRECTIVES = (
96
+ DIRECTIVES_2_0 + DIRECTIVES_DRAFT
97
+ ).freeze
98
+
99
+ ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
100
+
101
+ # Think of default-src and report-uri as the beginning and end respectively,
102
+ # everything else is in between.
103
+ BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
104
+
105
+ VARIATIONS = {
106
+ "Chrome" => CHROME_DIRECTIVES,
107
+ "Opera" => CHROME_DIRECTIVES,
108
+ "Firefox" => FIREFOX_DIRECTIVES,
109
+ "Safari" => SAFARI_DIRECTIVES,
110
+ "Other" => CHROME_DIRECTIVES
111
+ }.freeze
112
+
113
+ OTHER = "Other".freeze
114
+
115
+ DIRECTIVE_VALUE_TYPES = {
116
+ BASE_URI => :source_list,
117
+ BLOCK_ALL_MIXED_CONTENT => :boolean,
118
+ CHILD_SRC => :source_list,
119
+ CONNECT_SRC => :source_list,
120
+ DEFAULT_SRC => :source_list,
121
+ FONT_SRC => :source_list,
122
+ FORM_ACTION => :source_list,
123
+ FRAME_ANCESTORS => :source_list,
124
+ FRAME_SRC => :source_list,
125
+ IMG_SRC => :source_list,
126
+ MANIFEST_SRC => :source_list,
127
+ MEDIA_SRC => :source_list,
128
+ OBJECT_SRC => :source_list,
129
+ PLUGIN_TYPES => :source_list,
130
+ REFLECTED_XSS => :string,
131
+ REPORT_URI => :source_list,
132
+ SANDBOX => :string,
133
+ SCRIPT_SRC => :source_list,
134
+ STYLE_SRC => :source_list
135
+ }.freeze
136
+
137
+ CONFIG_KEY = :csp
138
+ STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
139
+ HTTP_SCHEME_REGEX = %r{\Ahttps?://}
140
+
141
+ WILDCARD_SOURCES = [
142
+ UNSAFE_EVAL,
143
+ UNSAFE_INLINE,
144
+ STAR
145
+ ]
78
146
 
79
147
  class << self
80
- def generate_nonce
81
- SecureRandom.base64(32).chomp
82
- end
83
-
84
- def set_nonce(controller, nonce = generate_nonce)
85
- controller.instance_variable_set(:@content_security_policy_nonce, nonce)
148
+ # Public: generate a header name, value array that is user-agent-aware.
149
+ #
150
+ # Returns a default policy if no configuration is provided, or a
151
+ # header name and value based on the config.
152
+ def make_header(config, user_agent)
153
+ header = new(config, user_agent)
154
+ [header.name, header.value]
86
155
  end
87
156
 
88
- def add_to_env(request, controller, config)
89
- set_nonce(controller)
90
- options = options_from_request(request).merge(:controller => controller)
91
- request.env[Constants::ENV_KEY] = {
92
- :config => config,
93
- :options => options,
94
- }
157
+ # Public: Validates that the configuration has a valid type, or that it is a valid
158
+ # source expression.
159
+ #
160
+ # Private: validates that a source expression:
161
+ # 1. has a valid name
162
+ # 2. is an array of strings
163
+ # 3. does not contain any depreated, now invalid values (inline, eval, self, none)
164
+ #
165
+ # Does not validate the invididual values of the source expression (e.g.
166
+ # script_src => h*t*t*p: will not raise an exception)
167
+ def validate_config!(config)
168
+ return if config.nil? || config == OPT_OUT
169
+ raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
170
+ config.each do |key, value|
171
+ if key == :report_only
172
+ raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
173
+ else
174
+ validate_directive!(key, value)
175
+ end
176
+ end
95
177
  end
96
178
 
97
- def options_from_request(request)
98
- {
99
- :ssl => request.ssl?,
100
- :ua => request.env['HTTP_USER_AGENT'],
101
- :request_uri => request_uri_from_request(request),
102
- }
103
- end
179
+ # Public: combine the values from two different configs.
180
+ #
181
+ # original - the main config
182
+ # additions - values to be merged in
183
+ #
184
+ # raises an error if the original config is OPT_OUT
185
+ #
186
+ # 1. for non-source-list values (report_only, block_all_mixed_content),
187
+ # additions will overwrite the original value.
188
+ # 2. if a value in additions does not exist in the original config, the
189
+ # default-src value is included to match original behavior.
190
+ # 3. if a value in additions does exist in the original config, the two
191
+ # values are joined.
192
+ def combine_policies(original, additions)
193
+ if original == OPT_OUT
194
+ raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
195
+ end
104
196
 
105
- def request_uri_from_request(request)
106
- if request.respond_to?(:original_url)
107
- # rails 3.1+
108
- request.original_url
109
- else
110
- # rails 2/3.0
111
- request.url
197
+ # in case we would be appending to an empty directive, fill it with the default-src value
198
+ additions.keys.each do |directive|
199
+ unless original[directive] || !source_list?(directive)
200
+ original[directive] = original[:default_src]
201
+ end
112
202
  end
113
- end
114
203
 
115
- def symbol_to_hyphen_case sym
116
- sym.to_s.gsub('_', '-')
204
+ # merge the two hashes. combine (instead of overwrite) the array values
205
+ # when each hash contains a value for a given key.
206
+ original.merge(additions) do |directive, lhs, rhs|
207
+ if source_list?(directive)
208
+ lhs | rhs
209
+ else
210
+ rhs
211
+ end
212
+ end
117
213
  end
118
- end
119
214
 
120
- # +options+ param contains
121
- # :controller used for setting instance variables for nonces/hashes
122
- # :ssl_request used to determine if http_additions should be used
123
- # :ua the user agent (or just use Firefox/Chrome/MSIE/etc)
124
- #
125
- # :report used to determine what :ssl_request, :ua, and :request_uri are set to
126
- def initialize(config=nil, options={})
127
- return unless config
215
+ private
128
216
 
129
- if options[:request]
130
- options = options.merge(self.class.options_from_request(options[:request]))
217
+ def source_list?(directive)
218
+ DIRECTIVE_VALUE_TYPES[directive] == :source_list
131
219
  end
132
220
 
133
- @controller = options[:controller]
134
- @ua = options[:ua]
135
- @ssl_request = !!options.delete(:ssl)
136
- @request_uri = options.delete(:request_uri)
137
-
138
- # Config values can be string, array, or lamdba values
139
- @config = config.inject({}) do |hash, (key, value)|
140
- config_val = if value.respond_to?(:call)
141
- warn "[DEPRECATION] secure_headers 3.x will not support procs as config values."
142
- value.call(@controller)
143
- else
144
- value
145
- end
146
-
147
- if ALL_DIRECTIVES.include?(key.to_sym) # directives need to be normalized to arrays of strings
148
- if config_val.is_a? String
149
- warn "[DEPRECATION] A String was supplied for directive #{key}. secure_headers 3.x will require all directives to be arrays of strings."
150
- config_val = config_val.split
221
+ # Private: Validates that the configuration has a valid type, or that it is a valid
222
+ # source expression.
223
+ def validate_directive!(key, value)
224
+ case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[key]
225
+ when :boolean
226
+ unless boolean?(value)
227
+ raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value")
151
228
  end
152
- if config_val.is_a?(Array)
153
- config_val = config_val.map do |val|
154
- translate_dir_value(val)
155
- end.flatten.uniq
229
+ when :string
230
+ unless value.is_a?(String)
231
+ raise ContentSecurityPolicyConfigError.new("#{key} Must be a string. Found #{config.class}: #{config} value")
156
232
  end
233
+ else
234
+ validate_source_expression!(key, value)
157
235
  end
158
-
159
- hash[key] = config_val
160
- hash
161
236
  end
162
237
 
163
- @http_additions = @config.delete(:http_additions)
164
- @disable_img_src_data_uri = !!@config.delete(:disable_img_src_data_uri)
165
- @tag_report_uri = !!@config.delete(:tag_report_uri)
166
- @script_hashes = @config.delete(:script_hashes) || []
167
- @app_name = @config.delete(:app_name)
168
- @app_name = @app_name.call(@controller) if @app_name.respond_to?(:call)
169
- @enforce = @config.delete(:enforce)
170
- @enforce = @enforce.call(@controller) if @enforce.respond_to?(:call)
171
- @enforce = !!@enforce
172
-
173
- # normalize and tag the report-uri
174
- if @config[:report_uri]
175
- @config[:report_uri] = @config[:report_uri].map do |report_uri|
176
- if report_uri.start_with?('//')
177
- report_uri = if @ssl_request
178
- "https:" + report_uri
179
- else
180
- "http:" + report_uri
181
- end
182
- end
238
+ # Private: validates that a source expression:
239
+ # 1. has a valid name
240
+ # 2. is an array of strings
241
+ # 3. does not contain any depreated, now invalid values (inline, eval, self, none)
242
+ #
243
+ # Does not validate the invididual values of the source expression (e.g.
244
+ # script_src => h*t*t*p: will not raise an exception)
245
+ def validate_source_expression!(key, value)
246
+ # source expressions
247
+ unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(key)
248
+ raise ContentSecurityPolicyConfigError.new("Unknown directive #{key}")
249
+ end
250
+ unless value.is_a?(Array) && value.all? { |v| v.is_a?(String) }
251
+ raise ContentSecurityPolicyConfigError.new("#{key} must be an array of strings")
252
+ end
183
253
 
184
- if @tag_report_uri
185
- report_uri = "#{report_uri}?enforce=#{@enforce}"
186
- report_uri += "&app_name=#{@app_name}" if @app_name
254
+ value.each do |source_expression|
255
+ if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(source_expression)
256
+ raise ContentSecurityPolicyConfigError.new("#{key} contains an invalid keyword source (#{source_expression}). This value must be single quoted.")
187
257
  end
188
- report_uri
189
258
  end
190
259
  end
191
260
 
192
- add_script_hashes if @script_hashes.any?
193
- strip_unsupported_directives
261
+ def boolean?(value)
262
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
263
+ end
194
264
  end
195
265
 
196
- ##
197
- # Return or initialize the nonce value used for this header.
198
- # If a reference to a controller is passed in the config, this method
199
- # will check if a nonce has already been set and use it.
200
- def nonce
201
- @nonce ||= @controller.instance_variable_get(:@content_security_policy_nonce) || self.class.generate_nonce
266
+
267
+ def initialize(config = nil, user_agent = OTHER)
268
+ config = Configuration.deep_copy(DEFAULT_CONFIG) unless config
269
+ @config = config
270
+ @parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
271
+ user_agent
272
+ else
273
+ UserAgent.parse(user_agent)
274
+ end
275
+ @report_only = !!@config[:report_only]
276
+ @script_nonce = @config[:script_nonce]
277
+ @style_nonce = @config[:style_nonce]
202
278
  end
203
279
 
204
280
  ##
205
281
  # Returns the name to use for the header. Either "Content-Security-Policy" or
206
282
  # "Content-Security-Policy-Report-Only"
207
283
  def name
208
- base = HEADER_NAME
209
- if !@enforce
210
- base += "-Report-Only"
284
+ if @report_only
285
+ REPORT_ONLY
286
+ else
287
+ HEADER_NAME
211
288
  end
212
- base
213
289
  end
214
290
 
215
291
  ##
216
292
  # Return the value of the CSP header
217
293
  def value
218
- return @config if @config.is_a?(String)
219
- if @config
294
+ @value ||= if @config
220
295
  build_value
221
296
  else
222
- DEFAULT_CSP_HEADER
297
+ DEFAULT_VALUE
223
298
  end
224
299
  end
225
300
 
226
- def to_json
227
- build_value
228
- @config.inject({}) do |hash, (key, value)|
229
- if ALL_DIRECTIVES.include?(key)
230
- hash[key.to_s.gsub(/(\w+)_(\w+)/, "\\1-\\2")] = value
231
- end
232
- hash
233
- end.to_json
234
- end
301
+ private
235
302
 
236
- def self.from_json(*json_configs)
237
- json_configs.inject({}) do |combined_config, one_config|
238
- config = JSON.parse(one_config).inject({}) do |hash, (key, value)|
239
- hash[key.gsub(/(\w+)-(\w+)/, "\\1_\\2").to_sym] = value
240
- hash
241
- end
242
- combined_config.merge(config) do |_, lhs, rhs|
243
- lhs | rhs
303
+ # Private: converts the config object into a string representing a policy.
304
+ # Places default-src at the first directive and report-uri as the last. All
305
+ # others are presented in alphabetical order.
306
+ #
307
+ # Unsupported directives are filtered based on the user agent.
308
+ #
309
+ # Returns a content security policy header value.
310
+ def build_value
311
+ directives.map do |directive_name|
312
+ case DIRECTIVE_VALUE_TYPES[directive_name]
313
+ when :boolean
314
+ symbol_to_hyphen_case(directive_name)
315
+ when :string
316
+ [symbol_to_hyphen_case(directive_name), @config[directive_name]].join(" ")
317
+ else
318
+ build_directive(directive_name)
244
319
  end
245
- end
320
+ end.join("; ")
246
321
  end
247
322
 
248
- private
323
+ # Private: builds a string that represents one directive in a minified form.
324
+ # If a directive contains *, all other values are omitted.
325
+ # If a directive contains 'none' but has other values, 'none' is ommitted.
326
+ # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
327
+ #
328
+ # directive_name - a symbol representing the various ALL_DIRECTIVES
329
+ #
330
+ # Returns a string representing a directive.
331
+ def build_directive(directive_name)
332
+ source_list = @config[directive_name].compact
249
333
 
250
- def add_script_hashes
251
- @config[:script_src] << @script_hashes.map {|hash| "'#{hash}'"} << ["'unsafe-inline'"]
252
- end
334
+ value = if source_list.include?(STAR)
335
+ # Discard trailing entries (excluding unsafe-*) since * accomplishes the same.
336
+ source_list.select { |value| WILDCARD_SOURCES.include?(value) }
337
+ else
338
+ populate_nonces(directive_name, source_list)
253
339
 
254
- def build_value
255
- raise "Expected to find default_src directive value" unless @config[:default_src]
256
- append_http_additions unless ssl_request?
257
- generic_directives
258
- end
340
+ # Discard any 'none' values if more directives are supplied since none may override values.
341
+ source_list.reject! { |value| value == NONE } if source_list.length > 1
259
342
 
260
- def append_http_additions
261
- return unless @http_additions
262
- @http_additions.each do |k, v|
263
- @config[k] ||= []
264
- @config[k] << v
343
+ # remove schemes and dedup source expressions
344
+ source_list = strip_source_schemes(source_list) unless directive_name == REPORT_URI
345
+ dedup_source_list(source_list).join(" ")
265
346
  end
347
+ [symbol_to_hyphen_case(directive_name), value].join(" ")
266
348
  end
267
349
 
268
- def translate_dir_value val
269
- if %w{inline eval}.include?(val)
270
- warn "[DEPRECATION] using inline/eval is not suppored in secure_headers 3.x. Instead use 'unsafe-inline'/'unsafe-eval' instead."
271
- val == 'inline' ? "'unsafe-inline'" : "'unsafe-eval'"
272
- elsif %{self none}.include?(val)
273
- warn "[DEPRECATION] using self/none is not suppored in secure_headers 3.x. Instead use 'self'/'none' instead."
274
- "'#{val}'"
275
- elsif val == 'nonce'
276
- if supports_nonces?
277
- self.class.set_nonce(@controller, nonce)
278
- ["'nonce-#{nonce}'", "'unsafe-inline'"]
279
- else
280
- "'unsafe-inline'"
350
+ # Removes duplicates and sources that already match an existing wild card.
351
+ #
352
+ # e.g. *.github.com asdf.github.com becomes *.github.com
353
+ def dedup_source_list(sources)
354
+ sources = sources.uniq
355
+ wild_sources = sources.select { |source| source =~ STAR_REGEXP }
356
+
357
+ if wild_sources.any?
358
+ sources.reject do |source|
359
+ !wild_sources.include?(source) &&
360
+ wild_sources.any? { |pattern| File.fnmatch(pattern, source) }
281
361
  end
282
362
  else
283
- val
363
+ sources
284
364
  end
285
365
  end
286
366
 
287
- # ensures defualt_src is first and report_uri is last
288
- def generic_directives
289
- header_value = build_directive(:default_src)
290
- data_uri = @disable_img_src_data_uri ? [] : ["data:"]
291
- if @config[:img_src]
292
- @config[:img_src] = @config[:img_src] + data_uri unless @config[:img_src].include?('data:')
293
- else
294
- @config[:img_src] = @config[:default_src] + data_uri
367
+ # Private: append a nonce to the script/style directories if script_nonce
368
+ # or style_nonce are provided.
369
+ def populate_nonces(directive, source_list)
370
+ case directive
371
+ when SCRIPT_SRC
372
+ append_nonce(source_list, @script_nonce)
373
+ when STYLE_SRC
374
+ append_nonce(source_list, @style_nonce)
295
375
  end
376
+ end
296
377
 
297
- (ALL_DIRECTIVES - [:default_src, :report_uri]).each do |directive_name|
298
- if @config[directive_name]
299
- header_value += build_directive(directive_name)
378
+ # Private: adds a nonce or 'unsafe-inline' depending on browser support.
379
+ # If a nonce is populated, inline content is assumed.
380
+ #
381
+ # While CSP is backward compatible in that a policy with a nonce will ignore
382
+ # unsafe-inline, this is more concise.
383
+ def append_nonce(source_list, nonce)
384
+ if nonce
385
+ if nonces_supported?
386
+ source_list << "'nonce-#{nonce}'"
387
+ else
388
+ source_list << UNSAFE_INLINE
300
389
  end
301
390
  end
302
-
303
- header_value += build_directive(:report_uri) if @config[:report_uri]
304
-
305
- header_value.strip
306
391
  end
307
392
 
308
- def build_directive(key)
309
- "#{self.class.symbol_to_hyphen_case(key)} #{@config[key].join(" ")}; "
393
+ # Private: return the list of directives that are supported by the user agent,
394
+ # starting with default-src and ending with report-uri.
395
+ def directives
396
+ [DEFAULT_SRC,
397
+ BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
398
+ REPORT_URI].flatten.select { |directive| @config.key?(directive) }
310
399
  end
311
400
 
312
- def strip_unsupported_directives
313
- @config.select! { |key, _| supported_directives.include?(key) }
401
+ # Private: Remove scheme from source expressions.
402
+ def strip_source_schemes(source_list)
403
+ source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
314
404
  end
315
405
 
406
+ # Private: determine which directives are supported for the given user agent.
407
+ #
408
+ # Returns an array of symbols representing the directives.
316
409
  def supported_directives
317
- @supported_directives ||= case USER_AGENT_PARSER.parse(@ua).family
318
- when "Chrome"
319
- CHROME_DIRECTIVES
320
- when "Safari"
321
- SAFARI_DIRECTIVES
322
- when "Firefox"
323
- FIREFOX_DIRECTIVES
324
- else
325
- DIRECTIVES_1_0
326
- end
410
+ @supported_directives ||= VARIATIONS[@parsed_ua.browser] || VARIATIONS[OTHER]
411
+ end
412
+
413
+ def nonces_supported?
414
+ @nonces_supported ||= MODERN_BROWSERS.include?(@parsed_ua.browser)
327
415
  end
328
416
 
329
- def supports_nonces?
330
- parsed_ua = USER_AGENT_PARSER.parse(@ua)
331
- ["Chrome", "Opera", "Firefox"].include?(parsed_ua.family)
417
+ def symbol_to_hyphen_case(sym)
418
+ sym.to_s.tr('_', '-')
332
419
  end
333
420
  end
334
421
  end