mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(proxy): pin no-store on every session-gated page
The dashboard, Insights, the workouts list, the medications list and the Coach server-prefetch their data and serialise one account's health record into the HTML document through a TanStack HydrationBoundary. Those pages are dynamic today only as a side effect of reading the session cookie — a property of the page code, not a guarantee. A stray revalidate export, a force-static segment, or a caching proxy in front of the app would be enough to retain one account's record and hand it to the next caller. Stamp `Cache-Control: private, no-store` at the edge instead, driven by the same predicate that governs the auth redirect, so a page cannot be session-gated without also being uncacheable. Public legal, auth and locale-catalog surfaces keep their cacheable posture; API routes keep managing their own caching. Two guards: the proxy test pins the header on gated pages and its absence on the public ones, and a structural test walks the app tree and refuses a prefetching page that sits on a public path or opts into static / revalidated rendering.
This commit is contained in:
1 parent
fadd21b3fd
commit
860cb61b0b
3 files changed
+256
-1
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Structural guard over the server-prefetching pages.
|
||||
*
|
||||
* A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the
|
||||
* caller's own health record into the HTML document. Two things must hold for
|
||||
* every such page, and neither is enforced by the type system:
|
||||
*
|
||||
* 1. It must be session-gated at the edge, so `src/proxy.ts` stamps
|
||||
* `Cache-Control: private, no-store` on the document. A prefetching page
|
||||
* placed on a public path would ship a record with no cache directive at
|
||||
* all.
|
||||
* 2. It must not opt into static or revalidated rendering. `export const
|
||||
* revalidate = <n>` or `dynamic = "force-static"` would let Next serve one
|
||||
* account's prefetched HTML to the next caller out of its own cache,
|
||||
* before the request ever reaches a header.
|
||||
*
|
||||
* The guard walks the app tree rather than naming files, so a NEW prefetching
|
||||
* page is covered the moment it lands.
|
||||
*/
|
||||
|
||||
const APP_DIR = join(process.cwd(), "src", "app");
|
||||
|
||||
/** Mirrors the public-path allowlist in `src/proxy.ts`. */
|
||||
const PUBLIC_ROUTE_PREFIXES = [
|
||||
"/auth/",
|
||||
"/privacy",
|
||||
"/about",
|
||||
"/c/",
|
||||
"/invite/",
|
||||
"/onboarding",
|
||||
"/mcp",
|
||||
"/i18n/",
|
||||
"/.well-known/",
|
||||
];
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "__tests__") continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (entry === "page.tsx" || entry === "page.ts") {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */
|
||||
function routePathFor(file: string): string {
|
||||
const rel = relative(APP_DIR, file).split(sep).slice(0, -1);
|
||||
const segments = rel.filter(
|
||||
// Route groups `(name)` and parallel/intercepting slots contribute no
|
||||
// URL segment.
|
||||
(s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"),
|
||||
);
|
||||
return "/" + segments.join("/");
|
||||
}
|
||||
|
||||
function isPublicRoute(routePath: string): boolean {
|
||||
return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p));
|
||||
}
|
||||
|
||||
const prefetchingPages = walk(APP_DIR)
|
||||
.map((file) => ({ file, source: readFileSync(file, "utf8") }))
|
||||
.filter(
|
||||
({ source }) =>
|
||||
source.includes("HydrationBoundary") && source.includes("dehydrate"),
|
||||
)
|
||||
.map(({ file, source }) => ({
|
||||
file,
|
||||
source,
|
||||
routePath: routePathFor(file),
|
||||
rel: relative(process.cwd(), file),
|
||||
}));
|
||||
|
||||
describe("server-prefetching pages cannot leak a cacheable record", () => {
|
||||
it("finds the prefetching pages at all (guard is not vacuous)", () => {
|
||||
expect(prefetchingPages.length).toBeGreaterThan(0);
|
||||
// The dashboard is the canonical one; if it stops matching, the detection
|
||||
// heuristic has drifted and every assertion below is silently skipped.
|
||||
expect(prefetchingPages.map((p) => p.routePath)).toContain("/");
|
||||
});
|
||||
|
||||
it("keeps every prefetching page behind the session gate", () => {
|
||||
const publicOnes = prefetchingPages.filter((p) =>
|
||||
isPublicRoute(p.routePath),
|
||||
);
|
||||
expect(
|
||||
publicOnes.map((p) => `${p.rel} (${p.routePath})`),
|
||||
"a page that dehydrates a record onto a public path gets no no-store header from the proxy",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets no prefetching page opt into static or revalidated rendering", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const page of prefetchingPages) {
|
||||
if (/export\s+const\s+revalidate\s*=/.test(page.source)) {
|
||||
offenders.push(`${page.rel}: exports revalidate`);
|
||||
}
|
||||
const dynamicMatch = page.source.match(
|
||||
/export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/,
|
||||
);
|
||||
if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") {
|
||||
offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
"a prefetched page must never be served from Next's own render cache",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Session-gated pages serialise one account's health record into the HTML
|
||||
* document — the dashboard, Insights, the workouts list, the medications
|
||||
* list and the Coach all server-prefetch their data through a TanStack
|
||||
* `HydrationBoundary`, so the record ships inside the payload rather than
|
||||
* arriving over a later fetch.
|
||||
*
|
||||
* Those pages are dynamic today only because they read the session cookie.
|
||||
* That is a property of the page code, not a guarantee — so the proxy pins
|
||||
* `Cache-Control: private, no-store` on every session-gated page response.
|
||||
* `private` bars shared caches (a CDN, a reverse proxy); `no-store` also
|
||||
* bars the browser disk cache and the bfcache snapshot.
|
||||
*
|
||||
* These tests pin both sides: gated pages carry the header, and the public
|
||||
* surfaces that are deliberately cacheable do not.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function makeRequest(pathname: string): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: { cookie: "healthlog_session=sess-1" },
|
||||
});
|
||||
}
|
||||
|
||||
/** The pages that server-prefetch and dehydrate a record into the HTML. */
|
||||
const PREFETCHING_PAGES = [
|
||||
"/",
|
||||
"/coach",
|
||||
"/insights",
|
||||
"/insights/workouts",
|
||||
"/medications",
|
||||
];
|
||||
|
||||
/** Other session-gated surfaces that render account data. */
|
||||
const OTHER_GATED_PAGES = [
|
||||
"/documents",
|
||||
"/settings/profile",
|
||||
"/admin/users",
|
||||
"/mood",
|
||||
];
|
||||
|
||||
describe("proxy.ts no-store on session-gated pages", () => {
|
||||
it.each(PREFETCHING_PAGES)(
|
||||
"sets private, no-store on the prefetching page %s",
|
||||
(path) => {
|
||||
const res = proxy(makeRequest(path));
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
expect(cacheControl).toContain("no-store");
|
||||
expect(cacheControl).toContain("private");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(OTHER_GATED_PAGES)(
|
||||
"sets private, no-store on the gated page %s",
|
||||
(path) => {
|
||||
const res = proxy(makeRequest(path));
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
expect(cacheControl).toContain("no-store");
|
||||
expect(cacheControl).toContain("private");
|
||||
},
|
||||
);
|
||||
|
||||
it("still emits no-store when the request carries no session cookie", () => {
|
||||
// The unauthenticated caller gets a redirect to /auth/login, which never
|
||||
// reaches the header block — assert the redirect rather than a header, so
|
||||
// the test states what actually happens on that arm.
|
||||
const res = proxy(new NextRequest("http://localhost/medications"));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain("/auth/login");
|
||||
});
|
||||
|
||||
it("leaves the public legal + auth surfaces cacheable", () => {
|
||||
for (const path of ["/privacy", "/about", "/auth/login"]) {
|
||||
const res = proxy(makeRequest(path));
|
||||
expect(res.headers.get("cache-control"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the immutable locale catalog cacheable", () => {
|
||||
const res = proxy(makeRequest("/i18n/en"));
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not blanket API routes, which manage their own caching", () => {
|
||||
const res = proxy(makeRequest("/api/user/avatar/user-1"));
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the stricter share-link posture on /c/<token>", () => {
|
||||
const token = "hls_000000000000000000000000000000000000000000000000";
|
||||
const res = proxy(makeRequest(`/c/${token}`));
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
expect(cacheControl).toContain("no-store");
|
||||
expect(cacheControl).toContain("must-revalidate");
|
||||
});
|
||||
});
|
||||
+26
-1
@@ -245,7 +245,11 @@ export function proxy(request: NextRequest) {
|
||||
const isApiRoute = pathname.startsWith("/api/");
|
||||
const isStaticFile = /\.\w+$/.test(pathname);
|
||||
const isPublic = isPublicPath(pathname);
|
||||
if (!isApiRoute && !isStaticFile && !isPublic) {
|
||||
// Every page behind the session gate. The same predicate drives the
|
||||
// auth redirect below and the `no-store` header further down, so a page
|
||||
// can never be session-gated without also being uncacheable.
|
||||
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
|
||||
if (isSessionGatedPage) {
|
||||
const hasSession = request.cookies.has("healthlog_session");
|
||||
if (!hasSession) {
|
||||
return NextResponse.redirect(new URL("/auth/login", request.url));
|
||||
@@ -335,6 +339,27 @@ export function proxy(request: NextRequest) {
|
||||
// Both serve routes take the narrow, document-only header posture.
|
||||
const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute;
|
||||
|
||||
// Session-gated pages carry one account's health record in the document
|
||||
// itself. Several of them server-prefetch their data and serialise it into
|
||||
// the HTML through a TanStack `HydrationBoundary` — the dashboard, Insights,
|
||||
// the workouts list, the medications list, the Coach. Those pages are
|
||||
// dynamic today only as a side effect of reading the session cookie, which
|
||||
// is a property of the page code, not a guarantee: a stray `revalidate`
|
||||
// export, a `force-static` segment, or a caching proxy in front of the app
|
||||
// would be enough to retain one account's record and hand it to the next
|
||||
// caller. Pin the guarantee at the edge instead, where no page-level change
|
||||
// can weaken it.
|
||||
//
|
||||
// `private` bars shared caches (CDN, reverse proxy) outright; `no-store`
|
||||
// additionally stops the browser's own disk cache and the bfcache snapshot,
|
||||
// so a back-navigation after logout cannot repaint the record. The public
|
||||
// surfaces (`/auth/*`, `/privacy`, `/about`, `/i18n/*`) are excluded by the
|
||||
// same predicate that governs the auth redirect, so the statically
|
||||
// rendered marketing/legal pages stay cacheable.
|
||||
if (isSessionGatedPage) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
|
||||
// Security headers
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");
|
||||
|
||||
Reference in new issue
Block a user