rack-cors 1.0.2 → 2.0.2

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