feat(middleware): extend cors with Prism and Middleware
This commit is contained in:
1 parent
780254dfeb
commit
93d3494ecc
4 files changed
+190
-6
No files matched your search
@@ -0,0 +1,75 @@
|
|||||||
|
module Prism
|
||||||
|
module Middleware
|
||||||
|
# Handles the CORS preflight (OPTIONS with Access-Control-Request-Method)
|
||||||
|
# and stamps Access-Control-* response headers on matching simple/actual
|
||||||
|
# requests, per the whatwg fetch spec's precedence: no Origin header means
|
||||||
|
# this is not a cross-origin request at all, so it passes through untouched.
|
||||||
|
class Cors
|
||||||
|
def initialize(app, registry: Prism::Cors)
|
||||||
|
@app = app
|
||||||
|
@registry = registry
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
origin = env['HTTP_ORIGIN']
|
||||||
|
return @app.call(env) unless origin
|
||||||
|
|
||||||
|
match = @registry.resource_for(origin, format_agnostic_path(env['PATH_INFO']))
|
||||||
|
return @app.call(env) unless match
|
||||||
|
_rule, resource = match
|
||||||
|
|
||||||
|
if preflight?(env)
|
||||||
|
preflight_response(env, origin, resource)
|
||||||
|
else
|
||||||
|
status, headers, body = @app.call(env)
|
||||||
|
[status, apply_headers(headers, origin, resource), body]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Cors resources are registered against logical route paths
|
||||||
|
# ('/posts'), but PATH_INFO still carries an optional trailing
|
||||||
|
# ".json"/".xml" format extension at this point in the stack (the
|
||||||
|
# router only strips it internally while matching) - without this,
|
||||||
|
# a resource registered for '/posts' would never match '/posts.json'.
|
||||||
|
def format_agnostic_path(path)
|
||||||
|
path.sub(%r{\.[^./]+\z}, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def preflight?(env)
|
||||||
|
env['REQUEST_METHOD'] == 'OPTIONS' && !env['HTTP_ACCESS_CONTROL_REQUEST_METHOD'].to_s.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def preflight_response(env, origin, resource)
|
||||||
|
requested_method = env['HTTP_ACCESS_CONTROL_REQUEST_METHOD']
|
||||||
|
requested_headers = env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']
|
||||||
|
|
||||||
|
unless resource.allows_method?(requested_method) && resource.allows_headers?(requested_headers)
|
||||||
|
return [403, {}, ['CORS preflight rejected']]
|
||||||
|
end
|
||||||
|
|
||||||
|
headers = apply_headers({}, origin, resource)
|
||||||
|
headers['Access-Control-Allow-Methods'] = resource.methods.join(', ')
|
||||||
|
unless requested_headers.to_s.empty?
|
||||||
|
headers['Access-Control-Allow-Headers'] = requested_headers
|
||||||
|
end
|
||||||
|
headers['Access-Control-Max-Age'] = resource.max_age.to_s if resource.max_age
|
||||||
|
[204, headers, []]
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_headers(headers, origin, resource)
|
||||||
|
headers['Access-Control-Allow-Origin'] = origin
|
||||||
|
headers['Access-Control-Allow-Credentials'] = 'true' if resource.credentials
|
||||||
|
headers['Access-Control-Expose-Headers'] = resource.expose.join(', ') unless resource.expose.empty?
|
||||||
|
headers['Vary'] = append_vary(headers['Vary'], 'Origin')
|
||||||
|
headers
|
||||||
|
end
|
||||||
|
|
||||||
|
def append_vary(existing, value)
|
||||||
|
return value if existing.to_s.empty?
|
||||||
|
existing.split(',').map(&:strip).push(value).uniq.join(', ')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
module Prism
|
||||||
|
module Middleware
|
||||||
|
# Evaluates Prism::RateLimiter rules on every request, in the same
|
||||||
|
# precedence order as Rack::Attack: safelist bypasses everything,
|
||||||
|
# then blocklist (403), then throttles (429 with Retry-After), then
|
||||||
|
# tracks (observation only, request still proceeds).
|
||||||
|
class RateLimiting
|
||||||
|
def initialize(app, limiter: Prism::RateLimiter)
|
||||||
|
@app = app
|
||||||
|
@limiter = limiter
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
request = Request.new(env)
|
||||||
|
return @app.call(env) if safelisted?(request)
|
||||||
|
return respond(403, 'Forbidden') if blocked?(request)
|
||||||
|
|
||||||
|
rule = throttled(request)
|
||||||
|
return respond(429, 'Too Many Requests', 'Retry-After' => rule.period.to_s) if rule
|
||||||
|
|
||||||
|
track(request)
|
||||||
|
@app.call(env)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def safelisted?(request)
|
||||||
|
@limiter.safelists.each_value.any? { |rule| rule.key_for(request) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocked?(request)
|
||||||
|
@limiter.blocklists.each_value.any? { |rule| rule.key_for(request) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def throttled(request)
|
||||||
|
@limiter.throttles.each_value do |rule|
|
||||||
|
key = rule.key_for(request)
|
||||||
|
next unless key
|
||||||
|
count = @limiter.store.count("#{rule.name}:#{key}", rule.period)
|
||||||
|
next unless count > rule.limit
|
||||||
|
@limiter.notify(:throttle, rule.name, request, key)
|
||||||
|
return rule
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def track(request)
|
||||||
|
@limiter.tracks.each_value do |rule|
|
||||||
|
value = rule.key_for(request)
|
||||||
|
@limiter.notify(:track, rule.name, request, value) if value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def respond(status, message, extra_headers = {})
|
||||||
|
[status, { 'Content-Type' => 'text/plain' }.merge(extra_headers), [message]]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,18 +3,21 @@ module Prism
|
|||||||
class Session
|
class Session
|
||||||
CookieName = 'prism.session'.freeze
|
CookieName = 'prism.session'.freeze
|
||||||
|
|
||||||
def initialize(app, secret:, cookie_name: CookieName, **options)
|
def initialize(app, secret:, previous_secrets: [], cookie_name: CookieName, **options)
|
||||||
@app = app
|
@app = app
|
||||||
@secret = secret
|
@secret = secret
|
||||||
|
@previous_secrets = previous_secrets
|
||||||
@cookie_name = cookie_name
|
@cookie_name = cookie_name
|
||||||
@options = options
|
@options = options
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
request = Request.new(env)
|
request = Request.new(env)
|
||||||
loaded = Prism::Session.decode(request.cookies[@cookie_name], @secret)
|
loaded = Prism::Session.decode(request.cookies[@cookie_name], @secret, previous_secrets: @previous_secrets)
|
||||||
session = Params.from(loaded)
|
session = Params.from(loaded)
|
||||||
env['prism.session'] = session
|
env['prism.session'] = session
|
||||||
|
env['prism.secret'] = @secret
|
||||||
|
env['prism.previous_secrets'] = @previous_secrets
|
||||||
original = session.dup
|
original = session.dup
|
||||||
|
|
||||||
status, headers, body = @app.call(env)
|
status, headers, body = @app.call(env)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ module Prism
|
|||||||
file = resolve(env['PATH_INFO'])
|
file = resolve(env['PATH_INFO'])
|
||||||
return @app.call(env) unless file
|
return @app.call(env) unless file
|
||||||
|
|
||||||
serve(file)
|
serve(file, head: method == 'HEAD', env: env)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -35,10 +35,57 @@ module Prism
|
|||||||
candidate
|
candidate
|
||||||
end
|
end
|
||||||
|
|
||||||
def serve(file)
|
def serve(file, head: false, env: {})
|
||||||
body = File.binread(file)
|
|
||||||
type = ContentTypes.fetch(File.extname(file).downcase, 'application/octet-stream')
|
type = ContentTypes.fetch(File.extname(file).downcase, 'application/octet-stream')
|
||||||
[200, { 'Content-Type' => type, 'Content-Length' => body.bytesize.to_s }, [body]]
|
etag, last_modified = StaticCache.weak_validators(File.size(file), File.mtime(file))
|
||||||
|
Static.respond_with_range(file, type, head: head, env: env, etag: etag, last_modified: last_modified)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared by Middleware::Assets so both give identical conditional-GET
|
||||||
|
# (304) and Range/206/416 behavior instead of duplicating this
|
||||||
|
# branching twice. `etag`/`last_modified` are already-formatted header
|
||||||
|
# values chosen by the caller (Assets can offer a strong,
|
||||||
|
# fingerprint-derived ETag; plain Static files only get a cheap weak
|
||||||
|
# one - see StaticCache).
|
||||||
|
def self.respond_with_range(file, type, head: false, env: {}, etag: nil, last_modified: nil)
|
||||||
|
size = File.size(file)
|
||||||
|
headers = { 'Content-Type' => type, 'Accept-Ranges' => 'bytes' }
|
||||||
|
headers['ETag'] = etag if etag
|
||||||
|
headers['Last-Modified'] = last_modified if last_modified
|
||||||
|
|
||||||
|
if StaticCache.not_modified?(env, etag, last_modified)
|
||||||
|
return [304, cache_only_headers(etag, last_modified), []]
|
||||||
|
end
|
||||||
|
|
||||||
|
range = env['HTTP_RANGE']
|
||||||
|
range = nil unless StaticCache.range_applicable?(env, etag, last_modified)
|
||||||
|
|
||||||
|
parsed = HttpRange.parse(range, size)
|
||||||
|
if parsed == :unsatisfiable
|
||||||
|
headers['Content-Range'] = "bytes */#{size}"
|
||||||
|
return [416, headers, []]
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed.nil?
|
||||||
|
headers['Content-Length'] = size.to_s
|
||||||
|
body = head ? [] : [File.binread(file)]
|
||||||
|
return [200, headers, body]
|
||||||
|
end
|
||||||
|
|
||||||
|
headers['Content-Range'] = "bytes #{parsed.start}-#{parsed.finish}/#{size}"
|
||||||
|
headers['Content-Length'] = parsed.length.to_s
|
||||||
|
body = head ? [] : [HttpRange.read_slice(file, parsed)]
|
||||||
|
[206, headers, body]
|
||||||
|
end
|
||||||
|
|
||||||
|
# A 304 response must not carry entity headers like Content-Type/
|
||||||
|
# Content-Length (there is no body), only the cache validators the
|
||||||
|
# client can keep comparing against next time.
|
||||||
|
def self.cache_only_headers(etag, last_modified)
|
||||||
|
headers = {}
|
||||||
|
headers['ETag'] = etag if etag
|
||||||
|
headers['Last-Modified'] = last_modified if last_modified
|
||||||
|
headers
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in new issue
Block a user