From 7773ac8f55fc35a48b91a2bbaa3eb5c5e4db8e65 Mon Sep 17 00:00:00 2001 From: DEls Date: Sun, 27 Sep 2026 02:07:07 +0200 Subject: [PATCH] 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 --- src/lib/agent/same-origin.test.ts | 26 ++++++++++++++++++++++++-- src/lib/agent/same-origin.ts | 13 +++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/lib/agent/same-origin.test.ts b/src/lib/agent/same-origin.test.ts index 2985f42..ab43cf3 100644 --- a/src/lib/agent/same-origin.test.ts +++ b/src/lib/agent/same-origin.test.ts @@ -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 = { 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); + }); }); diff --git a/src/lib/agent/same-origin.ts b/src/lib/agent/same-origin.ts index f9b03c1..41567eb 100644 --- a/src/lib/agent/same-origin.ts +++ b/src/lib/agent/same-origin.ts @@ -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; }