fix(auth): the invite landing redirects at the edge, no shell, no race

The proxy now answers `/invite/<token>` 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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:09:46 +02:00
1 parent 753438a329
commit cfca84a294
2 files changed
+114 -5

No files matched your search

+75 -5
View File
@@ -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 `<AuthShell>` 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/<token> 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}`,
);
});
});
+39
View File
@@ -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. `<AuthShell>` 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);