secure_headers 2.5.3 → 3.0.0.pre

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 (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