diff --git a/lib/prism/middleware/cors.rb b/lib/prism/middleware/cors.rb new file mode 100644 index 0000000..e99693f --- /dev/null +++ b/lib/prism/middleware/cors.rb @@ -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 diff --git a/lib/prism/middleware/rate_limiting.rb b/lib/prism/middleware/rate_limiting.rb new file mode 100644 index 0000000..f774045 --- /dev/null +++ b/lib/prism/middleware/rate_limiting.rb @@ -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 diff --git a/lib/prism/middleware/session.rb b/lib/prism/middleware/session.rb index 93d7ac8..834f249 100644 --- a/lib/prism/middleware/session.rb +++ b/lib/prism/middleware/session.rb @@ -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) diff --git a/lib/prism/middleware/static.rb b/lib/prism/middleware/static.rb index 513edb6..1eb61fa 100644 --- a/lib/prism/middleware/static.rb +++ b/lib/prism/middleware/static.rb @@ -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