rack-cors 1.0.2 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/rack/cors.rb CHANGED
@@ -1,38 +1,38 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'logger'
4
+ require_relative 'cors/resources'
5
+ require_relative 'cors/resource'
6
+ require_relative 'cors/result'
7
+ require_relative 'cors/version'
2
8
 
3
9
  module Rack
4
10
  class Cors
5
- HTTP_ORIGIN = 'HTTP_ORIGIN'.freeze
6
- HTTP_X_ORIGIN = 'HTTP_X_ORIGIN'.freeze
11
+ HTTP_ORIGIN = 'HTTP_ORIGIN'
12
+ HTTP_X_ORIGIN = 'HTTP_X_ORIGIN'
7
13
 
8
- HTTP_ACCESS_CONTROL_REQUEST_METHOD = 'HTTP_ACCESS_CONTROL_REQUEST_METHOD'.freeze
9
- HTTP_ACCESS_CONTROL_REQUEST_HEADERS = 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS'.freeze
14
+ HTTP_ACCESS_CONTROL_REQUEST_METHOD = 'HTTP_ACCESS_CONTROL_REQUEST_METHOD'
15
+ HTTP_ACCESS_CONTROL_REQUEST_HEADERS = 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS'
10
16
 
11
- PATH_INFO = 'PATH_INFO'.freeze
12
- REQUEST_METHOD = 'REQUEST_METHOD'.freeze
17
+ PATH_INFO = 'PATH_INFO'
18
+ REQUEST_METHOD = 'REQUEST_METHOD'
13
19
 
14
- RACK_LOGGER = 'rack.logger'.freeze
20
+ RACK_LOGGER = 'rack.logger'
15
21
  RACK_CORS =
16
- # retaining the old key for backwards compatibility
17
- ENV_KEY = 'rack.cors'.freeze
22
+ # retaining the old key for backwards compatibility
23
+ ENV_KEY = 'rack.cors'
18
24
 
19
- OPTIONS = 'OPTIONS'.freeze
20
- VARY = 'Vary'.freeze
21
- CONTENT_TYPE = 'Content-Type'.freeze
22
- TEXT_PLAIN = 'text/plain'.freeze
25
+ OPTIONS = 'OPTIONS'
23
26
 
24
27
  DEFAULT_VARY_HEADERS = ['Origin'].freeze
25
28
 
26
- # All CORS routes need to accept CORS simple headers at all times
27
- # {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers}
28
- CORS_SIMPLE_HEADERS = ['accept', 'accept-language', 'content-language', 'content-type'].freeze
29
-
30
- def initialize(app, opts={}, &block)
29
+ def initialize(app, opts = {}, &block)
31
30
  @app = app
32
31
  @debug_mode = !!opts[:debug]
33
32
  @logger = @logger_proc = nil
34
33
 
35
- if logger = opts[:logger]
34
+ logger = opts[:logger]
35
+ if logger
36
36
  if logger.respond_to? :call
37
37
  @logger_proc = opts[:logger]
38
38
  else
@@ -40,12 +40,12 @@ module Rack
40
40
  end
41
41
  end
42
42
 
43
- if block_given?
44
- if block.arity == 1
45
- block.call(self)
46
- else
47
- instance_eval(&block)
48
- end
43
+ return unless block_given?
44
+
45
+ if block.arity == 1
46
+ block.call(self)
47
+ else
48
+ instance_eval(&block)
49
49
  end
50
50
  end
51
51
 
@@ -66,24 +66,29 @@ module Rack
66
66
  def call(env)
67
67
  env[HTTP_ORIGIN] ||= env[HTTP_X_ORIGIN] if env[HTTP_X_ORIGIN]
68
68
 
69
+ path = evaluate_path(env)
70
+
69
71
  add_headers = nil
70
72
  if env[HTTP_ORIGIN]
71
73
  debug(env) do
72
- [ 'Incoming Headers:',
73
- " Origin: #{env[HTTP_ORIGIN]}",
74
- " Access-Control-Request-Method: #{env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]}",
75
- " Access-Control-Request-Headers: #{env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]}"
76
- ].join("\n")
74
+ ['Incoming Headers:',
75
+ " Origin: #{env[HTTP_ORIGIN]}",
76
+ " Path-Info: #{path}",
77
+ " Access-Control-Request-Method: #{env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]}",
78
+ " Access-Control-Request-Headers: #{env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]}"].join("\n")
77
79
  end
78
- if env[REQUEST_METHOD] == OPTIONS and env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]
79
- headers = process_preflight(env)
80
+
81
+ if env[REQUEST_METHOD] == OPTIONS && env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]
82
+ return [400, {}, []] unless Rack::Utils.valid_path?(path)
83
+
84
+ headers = process_preflight(env, path)
80
85
  debug(env) do
81
86
  "Preflight Headers:\n" +
82
- headers.collect{|kv| " #{kv.join(': ')}"}.join("\n")
87
+ headers.collect { |kv| " #{kv.join(': ')}" }.join("\n")
83
88
  end
84
89
  return [200, headers, []]
85
90
  else
86
- add_headers = process_cors(env)
91
+ add_headers = process_cors(env, path)
87
92
  end
88
93
  else
89
94
  Result.miss(env, Result::MISS_NO_ORIGIN)
@@ -92,7 +97,7 @@ module Rack
92
97
  # This call must be done BEFORE calling the app because for some reason
93
98
  # env[PATH_INFO] gets changed after that and it won't match. (At least
94
99
  # in rails 4.1.6)
95
- vary_resource = resource_for_path(env[PATH_INFO])
100
+ vary_resource = resource_for_path(path)
96
101
 
97
102
  status, headers, body = @app.call env
98
103
 
@@ -100,9 +105,7 @@ module Rack
100
105
  headers = add_headers.merge(headers)
101
106
  debug(env) do
102
107
  add_headers.each_pair do |key, value|
103
- if headers.has_key?(key)
104
- headers["X-Rack-CORS-Original-#{key}"] = value
105
- end
108
+ headers["x-rack-cors-original-#{key}"] = value if headers.key?(key)
106
109
  end
107
110
  end
108
111
  end
@@ -111,344 +114,106 @@ module Rack
111
114
  # response to be different depending on the Origin header value.
112
115
  # Better explained here: http://www.fastly.com/blog/best-practices-for-using-the-vary-header/
113
116
  if vary_resource
114
- vary = headers[VARY]
115
- cors_vary_headers = if vary_resource.vary_headers && vary_resource.vary_headers.any?
116
- vary_resource.vary_headers
117
- else
118
- DEFAULT_VARY_HEADERS
119
- end
120
- headers[VARY] = ((vary ? vary.split(/,\s*/) : []) + cors_vary_headers).uniq.join(', ')
117
+ vary = headers['vary']
118
+ cors_vary_headers = if vary_resource.vary_headers&.any?
119
+ vary_resource.vary_headers
120
+ else
121
+ DEFAULT_VARY_HEADERS
122
+ end
123
+ headers['vary'] = ((vary ? [vary].flatten.map { |v| v.split(/,\s*/) }.flatten : []) + cors_vary_headers).uniq.join(', ')
121
124
  end
122
125
 
123
- if debug? && result = env[RACK_CORS]
124
- result.append_header(headers)
125
- end
126
+ result = env[ENV_KEY]
127
+ result.append_header(headers) if debug? && result
126
128
 
127
129
  [status, headers, body]
128
130
  end
129
131
 
130
132
  protected
131
- def debug(env, message = nil, &block)
132
- (@logger || select_logger(env)).debug(message, &block) if debug?
133
- end
134
133
 
135
- def select_logger(env)
136
- @logger = if @logger_proc
137
- logger_proc = @logger_proc
138
- @logger_proc = nil
139
- logger_proc.call
134
+ def debug(env, message = nil, &block)
135
+ (@logger || select_logger(env)).debug(message, &block) if debug?
136
+ end
140
137
 
141
- elsif defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
142
- Rails.logger
138
+ def select_logger(env)
139
+ @logger = if @logger_proc
140
+ logger_proc = @logger_proc
141
+ @logger_proc = nil
142
+ logger_proc.call
143
143
 
144
- elsif env[RACK_LOGGER]
145
- env[RACK_LOGGER]
144
+ elsif defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
145
+ Rails.logger
146
146
 
147
- else
148
- ::Logger.new(STDOUT).tap { |logger| logger.level = ::Logger::Severity::DEBUG }
149
- end
150
- end
147
+ elsif env[RACK_LOGGER]
148
+ env[RACK_LOGGER]
151
149
 
152
- def all_resources
153
- @all_resources ||= []
154
- end
150
+ else
151
+ ::Logger.new(STDOUT).tap { |logger| logger.level = ::Logger::Severity::DEBUG }
152
+ end
153
+ end
155
154
 
156
- def process_preflight(env)
157
- result = Result.preflight(env)
155
+ def evaluate_path(env)
156
+ path = env[PATH_INFO]
158
157
 
159
- resource, error = match_resource(env)
160
- unless resource
161
- result.miss(error)
162
- return {}
163
- end
158
+ if path
159
+ path = Rack::Utils.unescape_path(path)
164
160
 
165
- return resource.process_preflight(env, result)
161
+ path = Rack::Utils.clean_path_info(path) if Rack::Utils.valid_path?(path)
166
162
  end
167
163
 
168
- def process_cors(env)
169
- resource, error = match_resource(env)
170
- if resource
171
- Result.hit(env)
172
- cors = resource.to_headers(env)
173
- cors
174
-
175
- else
176
- Result.miss(env, error)
177
- nil
178
- end
179
- end
164
+ path
165
+ end
180
166
 
181
- def resource_for_path(path_info)
182
- all_resources.each do |r|
183
- if found = r.resource_for_path(path_info)
184
- return found
185
- end
186
- end
187
- nil
188
- end
167
+ def all_resources
168
+ @all_resources ||= []
169
+ end
189
170
 
190
- def match_resource(env)
191
- path = env[PATH_INFO]
192
- origin = env[HTTP_ORIGIN]
193
-
194
- origin_matched = false
195
- all_resources.each do |r|
196
- if r.allow_origin?(origin, env)
197
- origin_matched = true
198
- if found = r.match_resource(path, env)
199
- return [found, nil]
200
- end
201
- end
202
- end
171
+ def process_preflight(env, path)
172
+ result = Result.preflight(env)
203
173
 
204
- [nil, origin_matched ? Result::MISS_NO_PATH : Result::MISS_NO_ORIGIN]
174
+ resource, error = match_resource(path, env)
175
+ unless resource
176
+ result.miss(error)
177
+ return {}
205
178
  end
206
179
 
207
- class Result
208
- HEADER_KEY = 'X-Rack-CORS'.freeze
209
-
210
- MISS_NO_ORIGIN = 'no-origin'.freeze
211
- MISS_NO_PATH = 'no-path'.freeze
212
-
213
- MISS_NO_METHOD = 'no-method'.freeze
214
- MISS_DENY_METHOD = 'deny-method'.freeze
215
- MISS_DENY_HEADER = 'deny-header'.freeze
216
-
217
- attr_accessor :preflight, :hit, :miss_reason
218
-
219
- def hit?
220
- !!hit
221
- end
222
-
223
- def preflight?
224
- !!preflight
225
- end
226
-
227
- def miss(reason)
228
- self.hit = false
229
- self.miss_reason = reason
230
- end
231
-
232
- def self.hit(env)
233
- r = Result.new
234
- r.preflight = false
235
- r.hit = true
236
- env[RACK_CORS] = r
237
- end
238
-
239
- def self.miss(env, reason)
240
- r = Result.new
241
- r.preflight = false
242
- r.hit = false
243
- r.miss_reason = reason
244
- env[RACK_CORS] = r
245
- end
246
-
247
- def self.preflight(env)
248
- r = Result.new
249
- r.preflight = true
250
- env[RACK_CORS] = r
251
- end
180
+ resource.process_preflight(env, result)
181
+ end
252
182
 
183
+ def process_cors(env, path)
184
+ resource, error = match_resource(path, env)
185
+ if resource
186
+ Result.hit(env)
187
+ cors = resource.to_headers(env)
188
+ cors
253
189
 
254
- def append_header(headers)
255
- headers[HEADER_KEY] = if hit?
256
- preflight? ? 'preflight-hit' : 'hit'
257
- else
258
- [
259
- (preflight? ? 'preflight-miss' : 'miss'),
260
- miss_reason
261
- ].join('; ')
262
- end
263
- end
190
+ else
191
+ Result.miss(env, error)
192
+ nil
264
193
  end
194
+ end
265
195
 
266
- class Resources
267
-
268
- attr_reader :resources
269
-
270
- def initialize
271
- @origins = []
272
- @resources = []
273
- @public_resources = false
274
- end
275
-
276
- def origins(*args, &blk)
277
- @origins = args.flatten.reject{ |s| s == '' }.map do |n|
278
- case n
279
- when Proc,
280
- Regexp,
281
- /^https?:\/\//,
282
- 'file://' then n
283
- when '*' then @public_resources = true; n
284
- else Regexp.compile("^[a-z][a-z0-9.+-]*:\\\/\\\/#{Regexp.quote(n)}$")
285
- end
286
- end.flatten
287
- @origins.push(blk) if blk
288
- end
289
-
290
- def resource(path, opts={})
291
- @resources << Resource.new(public_resources?, path, opts)
292
- end
293
-
294
- def public_resources?
295
- @public_resources
296
- end
297
-
298
- def allow_origin?(source,env = {})
299
- return true if public_resources?
300
-
301
- return !! @origins.detect do |origin|
302
- if origin.is_a?(Proc)
303
- origin.call(source,env)
304
- else
305
- origin === source
306
- end
307
- end
308
- end
309
-
310
- def match_resource(path, env)
311
- @resources.detect { |r| r.match?(path, env) }
312
- end
313
-
314
- def resource_for_path(path)
315
- @resources.detect { |r| r.matches_path?(path) }
316
- end
317
-
196
+ def resource_for_path(path_info)
197
+ all_resources.each do |r|
198
+ found = r.resource_for_path(path_info)
199
+ return found if found
318
200
  end
201
+ nil
202
+ end
319
203
 
320
- class Resource
321
- class CorsMisconfigurationError < StandardError
322
- def message
323
- "Allowing credentials for wildcard origins is insecure."\
324
- " Please specify more restrictive origins or set 'credentials' to false in your CORS configuration."
325
- end
326
- end
327
-
328
- attr_accessor :path, :methods, :headers, :expose, :max_age, :credentials, :pattern, :if_proc, :vary_headers
329
-
330
- def initialize(public_resource, path, opts={})
331
- raise CorsMisconfigurationError if public_resource && opts[:credentials] == true
332
-
333
- self.path = path
334
- self.credentials = public_resource ? false : (opts[:credentials] == true)
335
- self.max_age = opts[:max_age] || 1728000
336
- self.pattern = compile(path)
337
- self.if_proc = opts[:if]
338
- self.vary_headers = opts[:vary] && [opts[:vary]].flatten
339
- @public_resource = public_resource
340
-
341
- self.headers = case opts[:headers]
342
- when :any then :any
343
- when nil then nil
344
- else
345
- [opts[:headers]].flatten.collect{|h| h.downcase}
346
- end
347
-
348
- self.methods = case opts[:methods]
349
- when :any then [:get, :head, :post, :put, :patch, :delete, :options]
350
- else
351
- ensure_enum(opts[:methods]) || [:get]
352
- end.map{|e| e.to_s }
353
-
354
- self.expose = opts[:expose] ? [opts[:expose]].flatten : nil
355
- end
356
-
357
- def matches_path?(path)
358
- pattern =~ path
359
- end
360
-
361
- def match?(path, env)
362
- matches_path?(path) && (if_proc.nil? || if_proc.call(env))
363
- end
364
-
365
- def process_preflight(env, result)
366
- headers = {CONTENT_TYPE => TEXT_PLAIN}
367
-
368
- request_method = env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]
369
- if request_method.nil?
370
- result.miss(Result::MISS_NO_METHOD) and return headers
371
- end
372
- if !methods.include?(request_method.downcase)
373
- result.miss(Result::MISS_DENY_METHOD) and return headers
374
- end
375
-
376
- request_headers = env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
377
- if request_headers && !allow_headers?(request_headers)
378
- result.miss(Result::MISS_DENY_HEADER) and return headers
379
- end
380
-
381
- result.hit = true
382
- headers.merge(to_preflight_headers(env))
383
- end
384
-
385
- def to_headers(env)
386
- h = {
387
- 'Access-Control-Allow-Origin' => origin_for_response_header(env[HTTP_ORIGIN]),
388
- 'Access-Control-Allow-Methods' => methods.collect{|m| m.to_s.upcase}.join(', '),
389
- 'Access-Control-Expose-Headers' => expose.nil? ? '' : expose.join(', '),
390
- 'Access-Control-Max-Age' => max_age.to_s }
391
- h['Access-Control-Allow-Credentials'] = 'true' if credentials
392
- h
393
- end
394
-
395
- protected
396
- def public_resource?
397
- @public_resource
398
- end
399
-
400
- def origin_for_response_header(origin)
401
- return '*' if public_resource?
402
- origin
403
- end
404
-
405
- def to_preflight_headers(env)
406
- h = to_headers(env)
407
- if env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
408
- h.merge!('Access-Control-Allow-Headers' => env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS])
409
- end
410
- h
411
- end
412
-
413
- def allow_headers?(request_headers)
414
- headers = self.headers || []
415
- if headers == :any
416
- return true
417
- end
418
- request_headers = request_headers.split(/,\s*/) if request_headers.kind_of?(String)
419
- request_headers.all? do |header|
420
- header = header.downcase
421
- CORS_SIMPLE_HEADERS.include?(header) || headers.include?(header)
422
- end
423
- end
204
+ def match_resource(path, env)
205
+ origin = env[HTTP_ORIGIN]
424
206
 
425
- def ensure_enum(v)
426
- return nil if v.nil?
427
- [v].flatten
428
- end
207
+ origin_matched = false
208
+ all_resources.each do |r|
209
+ next unless r.allow_origin?(origin, env)
429
210
 
430
- def compile(path)
431
- if path.respond_to? :to_str
432
- special_chars = %w{. + ( )}
433
- pattern =
434
- path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
435
- case match
436
- when "*"
437
- "(.*?)"
438
- when *special_chars
439
- Regexp.escape(match)
440
- else
441
- "([^/?&#]+)"
442
- end
443
- end
444
- /^#{pattern}$/
445
- elsif path.respond_to? :match
446
- path
447
- else
448
- raise TypeError, path
449
- end
450
- end
211
+ origin_matched = true
212
+ found = r.match_resource(path, env)
213
+ return [found, nil] if found
451
214
  end
452
215
 
216
+ [nil, origin_matched ? Result::MISS_NO_PATH : Result::MISS_NO_ORIGIN]
217
+ end
453
218
  end
454
219
  end