Files
zeekay 806bf5c194 answer the preflight the browser actually asked
Two literal lists of the allowed request headers — one here, one in cloud's
middleware_edge.go — and this one had drifted four names behind: the console's
X-Actor-Id, X-Act-As-Project, X-Act-As-Org and X-CSRF-Token were added there and
never here. That is what a second literal list of the same thing always does.

A name absent from the list fails PREFLIGHT, and the browser reports that as an
opaque "TypeError: Failed to fetch" with nothing on the server side to see. The
request never arrives, so there is no log, no metric and no 4xx to notice. The
same gap cost the billing checkout its X-Idempotency-Key on
POST /v1/billing/subscribe/card, which meant no paid subscription could leave the
browser at all.

So stop maintaining the list. Echo the Access-Control-Request-Headers the browser
is asking about — the Fetch-standard way to say "all of them", and the only answer
that cannot go stale or drift from cloud's. Only well-formed tokens are echoed, so
a hand-written ask cannot fold a CRLF into a response header.

negotiation now runs only on the preflight, which is what its own comment says it
is for. An ordinary response was carrying allow-headers and max-age that a browser
ignores, and it is also the only way Vary can name the request-headers on the
preflight without claiming an ordinary response depends on them.
2026-08-07 22:02:48 -07:00

51 lines
2.1 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
// Untagged on purpose: [corsAllowHeaders] is a framework-free value, so it is
// exercised under BOTH builds. The transport suites around it are tagged because
// each drives one edge.
package gateway
import "testing"
// TestCORSAllowHeaders_AnswersTheAsk is the regression for the defect a literal
// allow-headers list guarantees: a client attaches a header nobody added, the
// preflight does not name it, and the browser kills the request with an opaque
// "TypeError: Failed to fetch" that never reaches a log.
//
// This copy had drifted four names behind cloud's, which is what a second literal
// list of the same thing always does. The names below are the ones it was missing
// — the console's four, and the X-Idempotency-Key the billing checkout stamps on
// POST /v1/billing/subscribe/card. Any header must work; that is the point.
func TestCORSAllowHeaders_AnswersTheAsk(t *testing.T) {
for _, ask := range []string{
"content-type, x-idempotency-key",
"x-actor-id, x-act-as-project, x-act-as-org, x-csrf-token",
"x-something-nobody-has-added-yet",
} {
if got := corsAllowHeaders(ask); got != ask {
t.Errorf("corsAllowHeaders(%q) = %q — the browser could not send what it asked about", ask, got)
}
}
}
// TestCORSAllowHeaders_EchoesTokensOnly: the echo is a header VALUE, so a
// hand-written ask must not be able to fold anything into the response.
func TestCORSAllowHeaders_EchoesTokensOnly(t *testing.T) {
cases := []struct{ ask, want string }{
{"", ""},
{"Content-Type", "Content-Type"},
{" x-a , x-b ", "x-a, x-b"},
{"x-a,,x-b", "x-a, x-b"}, // an empty name is not a name
{"x-ok\r\nX-Injected: 1", ""}, // CRLF makes the WHOLE name malformed
{"x-ok, x-bad\r\nX-Injected: 1", "x-ok"}, // …and only the malformed one is dropped
{"x-ok, bad header", "x-ok"}, // a space is not a tchar
{"x-ok, \"quoted\"", "x-ok"}, // nor is a quote
}
for _, tc := range cases {
if got := corsAllowHeaders(tc.ask); got != tc.want {
t.Errorf("corsAllowHeaders(%q) = %q, want %q", tc.ask, got, tc.want)
}
}
}