feat(middleware): extend cors with Prism and Middleware

This commit is contained in:
dualfroz committed 2026-09-07 17:57:19 +02:00
1 parent 780254dfeb
commit 93d3494ecc
4 files changed
+190 -6

No files matched your search

+75
View File
@@ -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
+59
View File
@@ -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
+5 -2
View File
@@ -3,18 +3,21 @@ module Prism
class Session
CookieName = 'prism.session'.freeze
def initialize(app, secret:, cookie_name: CookieName, **options)
def initialize(app, secret:, previous_secrets: [], cookie_name: CookieName, **options)
@app = app
@secret = secret
@previous_secrets = previous_secrets
@cookie_name = cookie_name
@options = options
end
def call(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)
env['prism.session'] = session
env['prism.secret'] = @secret
env['prism.previous_secrets'] = @previous_secrets
original = session.dup
status, headers, body = @app.call(env)
+51 -4
View File
@@ -20,7 +20,7 @@ module Prism
file = resolve(env['PATH_INFO'])
return @app.call(env) unless file
serve(file)
serve(file, head: method == 'HEAD', env: env)
end
private
@@ -35,10 +35,57 @@ module Prism
candidate
end
def serve(file)
body = File.binread(file)
def serve(file, head: false, env: {})
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