fix(ask): compare Origin against the Host header, not request.url

Confirm/Reject/Undo were unusable from any host that didn't happen to
match Next's internally-reported request.url hostname: dev serves on
127.0.0.1 (or a LAN IP) but request.url came back normalised to
"localhost", so every genuine same-origin click failed with "Cross-
site request refused" (403) even though the browser's real Origin
header was correct. Compare Origin's host against the request's own
Host header instead, which always reflects what the client actually
sent.

Found live-testing the fixed-cost Confirm/Undo flow: PocketBase showed
the row was never actually blocked from being created by the guard
itself, but every apply/reject/undo click from the browser 403'd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-27 02:07:07 +02:00
parent 738fcf9f6d
commit 7773ac8f55
2 changed files with 35 additions and 4 deletions

View file

@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import { isSameOrigin } from "./same-origin";
function req(url: string, origin?: string): Request {
return new Request(url, { method: "POST", headers: origin ? { origin } : {} });
/** `host` defaults to the URL's own host, matching a normal browser request. */
function req(url: string, origin?: string, host?: string): Request {
const headers: Record<string, string> = { host: host ?? new URL(url).host };
if (origin) headers.origin = origin;
return new Request(url, { method: "POST", headers });
}
describe("isSameOrigin", () => {
@ -17,4 +20,23 @@ describe("isSameOrigin", () => {
it("rejects a missing origin header", () => {
expect(isSameOrigin(req("https://ask.mega.co.za/api/agent/actions/a1/apply"))).toBe(false);
});
it("rejects a missing host header", () => {
const request = new Request("https://ask.mega.co.za/api/agent/actions/a1/apply", {
method: "POST",
headers: { origin: "https://ask.mega.co.za" },
});
expect(isSameOrigin(request)).toBe(false);
});
it("accepts a matching origin even when request.url reports a different (e.g. dev-normalised) host", () => {
// Regression: Next.js's dev server can report `request.url` with a
// canonical hostname ("localhost") that differs from the `Host`
// header the client actually sent (e.g. "127.0.0.1") — comparing
// against `request.url`'s host previously rejected every genuine
// same-origin request on such a mismatch.
expect(
isSameOrigin(req("http://localhost:6015/api/agent/actions/a1/apply", "http://127.0.0.1:6015", "127.0.0.1:6015")),
).toBe(true);
});
});

View file

@ -3,12 +3,21 @@
* There is no other auth yet — the actor is `x-ask-user` and the PIN is
* shared — so this is the one check standing between the card's own
* buttons and an arbitrary cross-site POST.
*
* Compares the Origin header against the request's `Host` header, not
* `request.url` — Next.js's dev server can report a `url` normalised to
* a canonical hostname (e.g. "localhost") regardless of the host the
* browser actually connected through (e.g. "127.0.0.1" or a LAN IP), so
* comparing against it rejects every genuine same-origin request that
* didn't happen to use that exact hostname. `Host` always reflects what
* the client actually sent.
*/
export function isSameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (!origin) return false;
const host = request.headers.get("host");
if (!origin || !host) return false;
try {
return new URL(origin).origin === new URL(request.url).origin;
return new URL(origin).host === host;
} catch {
return false;
}