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

Class Method Details

.allowed_originsObject

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

.configObject



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.

Returns:

  • (Boolean)


46
47
48
# File 'lib/tina4/cors.rb', line 46

def configured?
  !allowed_origins.empty?
end

.credentials?Boolean

Returns:

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

Returns:

  • (Boolean)


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