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; }