Module: Tina4::CorsMiddleware
- Defined in:
- lib/tina4/cors.rb
Overview
CORS policy — reads config from env, computes the response headers.
DENY BY DEFAULT (ADR-0018). With TINA4_CORS_ORIGINS unset, NO Access-Control-Allow-Origin is emitted and the browser's own CORS check blocks the cross-origin request. "*" still works, it just has to be asked for. Breaking change from the old permissive default.
CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's CORS check treats "*" as a literal (not a wildcard) once the request's credentials mode is "include", so Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true is rejected by every browser. Ruby emitted exactly that pair before 2026-07-31 (measured through the real Rack app): the credentials header was written unconditionally from config, with no wildcard guard, while Python, PHP and Node all guarded it.
VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin, i.e. whenever an allow-list is configured, on a MISS as well as a match. RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST NOT use this response to satisfy a later request unless the later request has the same values for the listed header fields as the original request". A constant "" genuinely does not vary and gets no Vary, which would only fragment a CDN's cache per-origin for a response identical to everyone. Access-Control-Allow-Methods / -Allow-Headers are static configured lists here, never derived from the request's Access-Control-Request- headers, so those field names do NOT belong in Vary.
Class Method Summary collapse
-
.allowed_origins ⇒ Object
The configured origins, split and emptied of blanks.
-
.apply_headers(response_headers, env = {}) ⇒ Object
Apply CORS headers to a response headers hash, merging Vary rather than clobbering a value another layer already set.
- .config ⇒ Object
-
.configured? ⇒ Boolean
Whether an operator has actually declared a CORS policy.
- .credentials? ⇒ Boolean
-
.origin_allowed?(origin) ⇒ Boolean
Check if a given origin is allowed by the configured policy.
-
.policy_headers(env = {}) ⇒ Object
The CORS policy headers for a request environment.
-
.preflight_response(env = {}, allow: nil) ⇒ Object
Handle a CORS preflight request, returns a Rack response array.
- .reset! ⇒ Object
Class Method Details
.allowed_origins ⇒ Object
The configured origins, split and emptied of blanks.
41 42 43 |
# File 'lib/tina4/cors.rb', line 41 def allowed_origins config[:origins].split(",").map(&:strip).reject(&:empty?) end |
.apply_headers(response_headers, env = {}) ⇒ Object
Apply CORS headers to a response headers hash, merging Vary rather than clobbering a value another layer already set.
129 130 131 132 133 134 135 136 137 138 |
# File 'lib/tina4/cors.rb', line 129 def apply_headers(response_headers, env = {}) policy_headers(env).each do |name, value| response_headers[name] = if name == "vary" merge_vary(response_headers["vary"], value) else value end end response_headers end |
.config ⇒ Object
31 32 33 |
# File 'lib/tina4/cors.rb', line 31 def config @config ||= load_config end |
.configured? ⇒ Boolean
Whether an operator has actually declared a CORS policy.
46 47 48 |
# File 'lib/tina4/cors.rb', line 46 def configured? !allowed_origins.empty? end |
.credentials? ⇒ Boolean
149 150 151 |
# File 'lib/tina4/cors.rb', line 149 def credentials? %w[true 1 yes].include?(config[:credentials].to_s.downcase) end |
.origin_allowed?(origin) ⇒ Boolean
Check if a given origin is allowed by the configured policy.
141 142 143 144 145 146 147 |
# File 'lib/tina4/cors.rb', line 141 def origin_allowed?(origin) allowed = allowed_origins return false if allowed.empty? return true if allowed.include?("*") allowed.include?(origin) end |
.policy_headers(env = {}) ⇒ Object
The CORS policy headers for a request environment.
This is the ONE place the policy is computed. preflight_response and apply_headers both call it, so the preflight path and the normal response path can never drift apart the way two implementations do.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/tina4/cors.rb', line 55 def policy_headers(env = {}) allowed = allowed_origins request_origin = env["HTTP_ORIGIN"] if allowed.empty? warn_once(:unconfigured, request_origin) if present?(request_origin) return {} end headers = {} wildcard = allowed.include?("*") # An allow-list decision reads the request Origin, so the response # varies by it — on a MISS too, or a shared cache can serve this # no-ACAO response to an origin that should have been allowed. headers["vary"] = "Origin" unless wildcard origin = resolve_origin(env) if origin.nil? warn_once(:denied, request_origin) if present?(request_origin) return headers end headers["access-control-allow-origin"] = origin headers["access-control-allow-methods"] = config[:methods] headers["access-control-allow-headers"] = config[:headers] headers["access-control-max-age"] = config[:max_age] if credentials? if origin == "*" warn_once(:wildcard_credentials, nil) else headers["access-control-allow-credentials"] = "true" end end headers end |
.preflight_response(env = {}, allow: nil) ⇒ Object
Handle a CORS preflight request, returns a Rack response array.
allow is the resource's REAL method set, and it is emitted as the
Allow header alongside the CORS headers. RFC 9110 s9.3.7 says a
successful OPTIONS response SHOULD carry Allow, and a preflight is an
OPTIONS response - dropping it means a bare OPTIONS and a preflight to
the same path answer two different questions.
This is CONFORMANCE, not a deviation. The frameworks' own OPTIONS handlers already do it - Django's View.options() sets Allow from _allowed_methods(), Express's router auto-answers OPTIONS with Allow. The add-on CORS libraries (cors npm, django-cors-headers, rack-cors, stack-cors, ASP.NET CORS) omit it, but that is a LAYERING artifact: each sits ahead of the framework, so short-circuiting the preflight also skips the framework's OPTIONS handler and the header it would have produced. Tina4 owns both paths, so it costs one header to answer both questions at once. See ADR-0013.
Note Allow and Access-Control-Allow-Methods are NOT the same thing and are not interchangeable: Allow is what the resource supports, ACAM is what the CORS policy permits cross-origin. A policy allowing DELETE on a GET-only route is still a 405.
The status is 204 whether the origin was allowed or denied — the browser does the blocking, and inventing a 403 here would be a second behaviour change for no gain.
119 120 121 122 123 124 125 |
# File 'lib/tina4/cors.rb', line 119 def preflight_response(env = {}, allow: nil) headers = policy_headers(env) # An unknown path yields "" - the same shape the bare-OPTIONS branch # uses - so a client can tell "nothing here" from "not told". headers["allow"] = Array(allow).join(", ") unless allow.nil? [204, headers, [""]] end |
.reset! ⇒ Object
35 36 37 38 |
# File 'lib/tina4/cors.rb', line 35 def reset! @config = nil @warned = nil end |