diff --git a/src/__tests__/proxy-invite-public.test.ts b/src/__tests__/proxy-invite-public.test.ts index feb7aee54..d7c2df685 100644 --- a/src/__tests__/proxy-invite-public.test.ts +++ b/src/__tests__/proxy-invite-public.test.ts @@ -8,6 +8,16 @@ import { NextRequest } from "next/server"; * visitor (scanning the admin QR) must reach it without the auth-gate * bounce to `/auth/login`. This guard locks the allowlist entry in place * so a future refactor of `src/proxy.ts` cannot silently re-gate it. + * + * v1.38.19 — the redirect moved here, to the edge. It used to be emitted by + * `src/app/invite/[token]/page.tsx`, and Next does not answer that with a + * server 307: it streams the root layout first and puts the redirect in the + * flight payload, so `` mounted on `/invite/…`, read the pathname + * as a protected route and raced its own `router.replace("/auth/login")` + * against the redirect. Answering at the edge means there is no HTML, no + * hydration and no race — the behaviour the page's doc comment already + * claimed. The page stays as the fallback for any path that bypasses the + * proxy matcher. */ vi.mock("@/lib/process-type", () => ({ @@ -33,19 +43,18 @@ function makeRequest( describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { it("does not 307 an unauthenticated /invite/ to /auth/login", () => { const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); - expect(res.status).toBe(200); - expect(res.headers.get("location")).toBeNull(); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); }); - it("admits a pending-onboarding session onto the invite landing", () => { + it("does not bounce a pending-onboarding session off the invite landing", () => { const res = proxy( makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1", hl_onboarding: "pending", }), ); - expect(res.status).toBe(200); - expect(res.headers.get("location")).toBeNull(); + expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); }); it("hardens the token-in-path surface against cache / index / referrer leaks", () => { @@ -67,3 +76,64 @@ describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { expect(res.headers.get("location")).toMatch(/\/auth\/login/); }); }); + +describe("proxy.ts /invite edge redirect (v1.38.19)", () => { + it("307s a shape-valid token straight onto the registration form", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); + + it("307s a malformed segment onto a bare registration form", () => { + // Never echo an attacker-controlled segment into the target — a + // malformed token behaves exactly like a visitor with no invite. + const res = proxy(makeRequest("/invite/not-a-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe("http://localhost/auth/register"); + }); + + it("ignores anything after the token segment", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}/extra`)); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); + + it.each([ + ["an uppercase hex body", `hlv_${"A".repeat(64)}`], + ["a short body", `hlv_${"a".repeat(63)}`], + ["a long body", `hlv_${"a".repeat(65)}`], + ["the wrong prefix", `hlk_${"a".repeat(64)}`], + ["an empty segment", ""], + ])("drops %s rather than echoing it into the target", (_label, segment) => { + const res = proxy(makeRequest(`/invite/${segment}`)); + expect(res.headers.get("location")).toBe("http://localhost/auth/register"); + }); + + it("keeps the token hardening on the malformed redirect too", () => { + const res = proxy(makeRequest("/invite/not-a-token")); + expect(res.headers.get("Cache-Control")).toBe( + "no-store, no-cache, must-revalidate", + ); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow"); + expect(res.headers.get("Referrer-Policy")).toBe("no-referrer"); + }); + + it("carries the baseline transport headers on the redirect hop", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("X-Frame-Options")).toBe("DENY"); + }); + + it("redirects a signed-in caller the same way, with no session read", () => { + const res = proxy( + makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1" }), + ); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index b516e9c0f..4a582c538 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -330,6 +330,45 @@ export function proxy(request: NextRequest) { } } + // v1.38.19 — the invite universal-link landing answers at the EDGE. + // + // `src/app/invite/[token]/page.tsx` has always redirected onto + // `/auth/register?invite=…`, but Next does not deliver that as a server 307: + // it streams the root layout first and puts the redirect in the RSC flight + // payload. `` therefore mounted on the pathname `/invite/…`, and + // before the entry added beside this one it read the route as protected, + // took a 401 from `/api/auth/me` and raced its own + // `router.replace("/auth/login")` against the redirect — logged out the + // login bounce won, and the visitor held a one-time invite on a page that + // cannot use it. Answering here means no HTML, no hydration, no race. + // + // The page stays as the fallback for any path that bypasses the matcher. + // The shape gate is the twin of `looksLikeInviteToken` in + // `src/lib/auth/invite-token.ts`, restated rather than imported: that module + // pulls the Prisma client, which has no business in the proxy bundle. A + // segment that fails it is dropped rather than reflected, so the target can + // never echo attacker-controlled text. + if (pathname.startsWith("/invite/")) { + const token = pathname.slice("/invite/".length).split("/")[0] ?? ""; + const target = new URL("/auth/register", request.url); + if (/^hlv_[0-9a-f]{64}$/.test(token)) { + target.searchParams.set("invite", token); + } + const redirected = applyBaselineSecurityHeaders( + NextResponse.redirect(target, 307), + ); + // The secret rides in the path of the request that produced this hop, so + // the hop itself carries the same defence the landing page earned in + // v1.17.0: uncacheable, unindexable, and never leaked as a `Referer`. + redirected.headers.set( + "Cache-Control", + "no-store, no-cache, must-revalidate", + ); + redirected.headers.set("X-Robots-Tag", "noindex, nofollow"); + redirected.headers.set("Referrer-Policy", "no-referrer"); + return redirected; + } + // Server-side route protection for pages (not API routes — those have their own getSession checks) const isApiRoute = pathname.startsWith("/api/"); const isStaticFile = /\.\w+$/.test(pathname);