From cfca84a2944ebca2b04581a6e9aea88163487b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:09:46 +0200 Subject: [PATCH] fix(auth): the invite landing redirects at the edge, no shell, no race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy now answers `/invite/` with a real 307 onto `/auth/register?invite=…` (or a bare `/auth/register` when the segment fails the `hlv_` shape gate), carrying the baseline transport headers plus the no-store / noindex / no-referrer hardening the token-in-path surface has had since v1.17.0. Before this the redirect came from the landing page, which Next delivers in the RSC flight payload rather than as a server 307 — the shell mounted first and raced it. At the edge there is no HTML and no hydration, so there is nothing to race. The page stays in place as the fallback for any path that bypasses the proxy matcher. --- src/__tests__/proxy-invite-public.test.ts | 80 +++++++++++++++++++++-- src/proxy.ts | 39 +++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) 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);