mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(02-12.1): reach fenced document routes through the app transport
A browser-issued <img src>, <iframe src> or <a href download> cannot carry the record-session assertion, so the fence refused all three on any session that had been inside a shared record — permanently, since none of them will ever send a header. - add a structural guard: no client file may name a record-fenced route in a src=, href= or window.location sink - fetch the thumbnail, the inline preview and the download through the app transport and hand the DOM an object URL, revoked on unmount - make the two API probes in the managed-profiles browser spec assert the same context the app beside them asserts
This commit is contained in:
1 parent
8fcbdcc0df
commit
ffe38c9bb6
7 files changed
+739
-27
No files matched your search
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* A fenced route may not be reached by a transport that cannot carry a header.
|
||||
*
|
||||
* The record-session fence is asserted on request HEADERS, attached inside
|
||||
* `src/lib/api/api-fetch.ts`. Three browser transports never go through that
|
||||
* wrapper and cannot be made to:
|
||||
*
|
||||
* * `<img src="/api/…">` — the browser issues the subresource itself;
|
||||
* * `<iframe src="/api/…">` — likewise, and it is a document load;
|
||||
* * `<a href="/api/…" download>` — a navigation, not a fetch.
|
||||
*
|
||||
* On a session that has never entered a shared record this is invisible: the
|
||||
* fence exempts `record_epoch = 0`, so a headerless request is served exactly
|
||||
* as it always was. The moment the session enters or leaves a shared record the
|
||||
* epoch moves, the exemption ends, and every one of those three gets
|
||||
* `403 sharing.access.denied` — permanently, because nothing about them will
|
||||
* ever start sending a header. A thumbnail that silently stops rendering after
|
||||
* somebody visits a shared record is the shape of the bug.
|
||||
*
|
||||
* The fix shape already existed in the repository before this guard did:
|
||||
* `src/components/documents/document-ai-transport.ts` reaches
|
||||
* `/api/documents/inbound/{id}/original` through `apiFetchRaw` and turns the
|
||||
* blob into a `File`. Everything here follows it — fetch through the app
|
||||
* transport, hand the DOM an object URL, revoke it on unmount.
|
||||
*
|
||||
* ## What this matcher can and cannot see
|
||||
*
|
||||
* It resolves ONE level of local aliasing: `const src = \`/api/…\`` followed by
|
||||
* `src={src}` is caught, because that is the shape the real offenders had and a
|
||||
* matcher that only read inline literals would have found two of the six and
|
||||
* reported success. It does NOT trace a path built by a helper, assembled
|
||||
* across two hops, or returned from a hook — a future offender in that shape
|
||||
* slips this guard. Said here rather than discovered later.
|
||||
*
|
||||
* It also only knows the routes it can RESOLVE. A path whose route module it
|
||||
* cannot find is reported separately rather than silently passing, because an
|
||||
* unresolvable path is the one case where a green result would mean nothing.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const API_ROOT = join(SRC, "app", "api");
|
||||
|
||||
/** Client-side trees. `src/app/api` is the server end and is excluded. */
|
||||
const CLIENT_ROOTS = [join(SRC, "components"), join(SRC, "app")];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"__tests__",
|
||||
"__mocks__",
|
||||
"node_modules",
|
||||
".next",
|
||||
"generated",
|
||||
]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) {
|
||||
if (p === API_ROOT) continue;
|
||||
walk(p, out);
|
||||
} else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".test.ts") &&
|
||||
!name.endsWith(".test.tsx")
|
||||
) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `/api/...` path template to its route module.
|
||||
*
|
||||
* `${…}` segments match any `[param]` directory, which is how
|
||||
* `/api/documents/inbound/${id}/thumbnail` finds
|
||||
* `src/app/api/documents/inbound/[id]/thumbnail/route.ts`. Query strings and
|
||||
* trailing fragments are dropped before matching.
|
||||
*/
|
||||
function resolveRouteModules(path: string): string[] {
|
||||
const clean = path.split(/[?#]/)[0];
|
||||
const segments = clean
|
||||
.replace(/^\/api\//, "")
|
||||
.split("/")
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
let dirs = [API_ROOT];
|
||||
for (const segment of segments) {
|
||||
const templated = segment.includes("${");
|
||||
const next: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
if (!templated && existsSync(join(dir, segment))) {
|
||||
next.push(join(dir, segment));
|
||||
continue;
|
||||
}
|
||||
// A templated segment is a WILDCARD over every child directory, literal
|
||||
// and dynamic alike. `${id}` finds `[id]`; `${provider}` finds
|
||||
// `withings`, `fitbit`, `whoop`, `google-health`. Taking only the
|
||||
// dynamic children would have silently dropped the second shape, and a
|
||||
// path that resolves to nothing reads as "no fenced route here".
|
||||
if (!templated) continue;
|
||||
for (const child of readdirSync(dir)) {
|
||||
const p = join(dir, child);
|
||||
if (statSync(p).isDirectory()) next.push(p);
|
||||
}
|
||||
}
|
||||
dirs = next;
|
||||
}
|
||||
return dirs
|
||||
.map((dir) => join(dir, "route.ts"))
|
||||
.filter((module) => existsSync(module));
|
||||
}
|
||||
|
||||
/** Does this route resolve a RECORD, and therefore consult the fence? */
|
||||
function isFenced(routeModule: string): boolean {
|
||||
const src = readFileSync(routeModule, "utf8");
|
||||
return (
|
||||
/\brequireRecordAuth\s*\(/.test(src) ||
|
||||
/\brequireGuardianAuth\s*\(/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
interface Reference {
|
||||
file: string;
|
||||
line: number;
|
||||
sink: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const API_LITERAL = /^[`"']((?:\/api\/)[^`"']*)[`"']$/;
|
||||
|
||||
/**
|
||||
* Every `/api/...` path handed to a headerless transport in one file.
|
||||
*
|
||||
* Sinks: `src=`, `href=`, and any `window.location` write. Each is resolved
|
||||
* against a one-level alias map built from the file's own
|
||||
* `const NAME = "/api/…"` declarations.
|
||||
*/
|
||||
/**
|
||||
* Comments are stripped before matching.
|
||||
*
|
||||
* A doc comment that NAMES `src="/api/…"` while explaining why the code no
|
||||
* longer does that is prose, not a transport — and this guard's own
|
||||
* explanations are written in exactly that shape. Commented-out code is
|
||||
* dropped with them, which is correct: it issues no request. Same treatment
|
||||
* and the same reason as `account-payload-consumer-guard.test.ts`.
|
||||
*/
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function headerlessReferences(file: string, source: string): Reference[] {
|
||||
const aliases = new Map<string, string>();
|
||||
// `const NAME = "/api/…"`, and the ternary form
|
||||
// `const NAME = cond ? \`/api/…\` : "#"` that the download links use. Both
|
||||
// are one hop, which is the documented limit of this resolution.
|
||||
for (const m of source.matchAll(
|
||||
/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*[^;\n]*?([`"'])(\/api\/[^`"']*)\2/g,
|
||||
)) {
|
||||
if (!aliases.has(m[1])) aliases.set(m[1], m[3]);
|
||||
}
|
||||
|
||||
const found: Reference[] = [];
|
||||
const record = (index: number, sink: string, raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const literal = API_LITERAL.exec(trimmed);
|
||||
const path = literal ? literal[1] : (aliases.get(trimmed) ?? null);
|
||||
if (path === null) return;
|
||||
found.push({
|
||||
file,
|
||||
line: source.slice(0, index).split("\n").length,
|
||||
sink,
|
||||
path,
|
||||
});
|
||||
};
|
||||
|
||||
// `src={expr}` / `src="literal"` and the same for `href`.
|
||||
//
|
||||
// The expression container is read with a brace COUNTER, not a
|
||||
// `[^}]*` class: a template literal like `` `/api/x/${id}/y` `` contains a
|
||||
// `}` of its own, and a non-counting matcher truncates at it and then fails
|
||||
// to recognise the literal at all. That is how the first draft of this guard
|
||||
// found two of six offenders and reported success.
|
||||
for (const m of source.matchAll(/\b(src|href)=/g)) {
|
||||
const start = m.index + m[0].length;
|
||||
const ch = source[start];
|
||||
if (ch === "{") {
|
||||
let depth = 0;
|
||||
let end = start;
|
||||
for (; end < source.length; end++) {
|
||||
if (source[end] === "{") depth++;
|
||||
else if (source[end] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
record(m.index, m[1], source.slice(start + 1, end));
|
||||
} else if (ch === '"' || ch === "'" || ch === "`") {
|
||||
const end = source.indexOf(ch, start + 1);
|
||||
if (end !== -1) record(m.index, m[1], source.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
// Any `window.location` write or navigation.
|
||||
for (const m of source.matchAll(
|
||||
/window\.location(?:\.href)?\s*=\s*([^;\n]+)|window\.location\.(?:assign|replace)\(\s*([^)\n]+)\)/g,
|
||||
)) {
|
||||
record(m.index, "window.location", m[1] ?? m[2] ?? "");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("no headerless browser transport reaches a fenced route", () => {
|
||||
const files = CLIENT_ROOTS.flatMap((root) => walk(root));
|
||||
const references = files.flatMap((file) =>
|
||||
headerlessReferences(
|
||||
file.slice(SRC.length + 1),
|
||||
stripComments(readFileSync(file, "utf8")),
|
||||
),
|
||||
);
|
||||
|
||||
it("finds a plausible client tree and a plausible set of references", () => {
|
||||
// Non-zero proof, twice: a walk that found nothing, or a matcher that
|
||||
// matched nothing, would make every assertion below vacuous.
|
||||
expect(files.length).toBeGreaterThan(200);
|
||||
expect(references.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it("resolves every referenced /api path to a route module", () => {
|
||||
// An unresolvable path is the one case where a green result would mean
|
||||
// nothing at all, so it fails loudly rather than being skipped.
|
||||
const unresolved = references.filter(
|
||||
(r) => resolveRouteModules(r.path).length === 0,
|
||||
);
|
||||
expect(unresolved.map((r) => `${r.file}:${r.line} → ${r.path}`)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a known fenced route, so the fenced check is live", () => {
|
||||
// Without this the leg below would pass for an `isFenced` that always
|
||||
// returned false.
|
||||
const thumbnail = resolveRouteModules(
|
||||
"/api/documents/inbound/${id}/thumbnail",
|
||||
);
|
||||
expect(thumbnail).toHaveLength(1);
|
||||
expect(isFenced(thumbnail[0])).toBe(true);
|
||||
const connect = resolveRouteModules("/api/withings/connect");
|
||||
expect(connect).toHaveLength(1);
|
||||
expect(isFenced(connect[0])).toBe(false);
|
||||
// The wildcard shape resolves to several real routes, none of them fenced.
|
||||
const providers = resolveRouteModules("/api/${provider}/connect");
|
||||
expect(providers.length).toBeGreaterThan(2);
|
||||
expect(providers.some(isFenced)).toBe(false);
|
||||
});
|
||||
|
||||
it("names no fenced route in a src=, href= or window.location sink", () => {
|
||||
const offenders = references.filter((r) =>
|
||||
resolveRouteModules(r.path).some(isFenced),
|
||||
);
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const report = offenders
|
||||
.map((o) => ` ❌ ${o.file}:${o.line} — ${o.sink}= ${o.path}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`${offenders.length} headerless reference(s) to a record-fenced route:\n${report}\n\n` +
|
||||
"These cannot carry the record-session fence headers, so they are refused with " +
|
||||
"403 sharing.access.denied on any session that has entered or left a shared " +
|
||||
"record. Fetch through `apiFetchRaw` and hand the DOM an object URL — see " +
|
||||
"`src/components/documents/document-ai-transport.ts` for the shape, and revoke " +
|
||||
"the URL when the component unmounts.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user