mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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,197 @@
|
||||
/**
|
||||
* Reaching a fenced route from the DOM without a headerless request.
|
||||
*
|
||||
* Two properties, both of which go wrong quietly:
|
||||
*
|
||||
* * the bytes travel through the app transport, so the request carries the
|
||||
* record-session assertion. A plain `fetch` here would reintroduce the
|
||||
* exact class `record-fence-headerless-transport-guard.test.ts` exists to
|
||||
* catch, and nothing visible would change until somebody entered a shared
|
||||
* record;
|
||||
* * every object URL is revoked. Getting that wrong leaks a decrypted health
|
||||
* document into the tab for as long as the tab lives, and the UI never
|
||||
* shows it. The hard case is a response that lands after the caller has
|
||||
* moved on — a fast scroll through the vault timeline changes the path far
|
||||
* more often than the network answers.
|
||||
*
|
||||
* The unit suite runs in `node` with no DOM, so the React hook cannot be
|
||||
* rendered here. That is why the lifecycle rule lives in
|
||||
* `createFencedBlobLoader` rather than inside the effect: an untested
|
||||
* revocation rule is exactly the kind that is wrong.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createFencedBlobLoader } from "@/hooks/use-fenced-object-url";
|
||||
import {
|
||||
__resetRecordFenceForTests,
|
||||
adoptRecordFenceState,
|
||||
} from "@/lib/api/record-fence";
|
||||
|
||||
const created: string[] = [];
|
||||
const revoked: string[] = [];
|
||||
let counter = 0;
|
||||
|
||||
/** A deferred blob response, so a load can be left in flight deliberately. */
|
||||
function pendingResponse() {
|
||||
let settle!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return {
|
||||
settle,
|
||||
fetch: vi.fn(async () => {
|
||||
await gate;
|
||||
return new Response(new Blob(["bytes"]));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
revoked.length = 0;
|
||||
counter = 0;
|
||||
__resetRecordFenceForTests();
|
||||
vi.stubGlobal("URL", {
|
||||
createObjectURL: vi.fn(() => {
|
||||
const url = `blob:mock/${counter++}`;
|
||||
created.push(url);
|
||||
return url;
|
||||
}),
|
||||
revokeObjectURL: vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
__resetRecordFenceForTests();
|
||||
});
|
||||
|
||||
describe("createFencedBlobLoader", () => {
|
||||
it("carries the record-session assertion on the request", async () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: "owner-1" });
|
||||
const fetchMock = vi.fn(async () => new Response(new Blob(["bytes"])));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await createFencedBlobLoader().load(
|
||||
"/api/documents/inbound/doc-1/thumbnail",
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
const headers = new Headers(init.headers);
|
||||
// The whole point: a browser-issued `<img src>` cannot send these, which is
|
||||
// why the bytes come through the transport instead.
|
||||
expect(headers.get("x-healthlog-record-epoch")).toBe("4");
|
||||
expect(headers.get("x-healthlog-record-scope")).toBe("owner-1");
|
||||
});
|
||||
|
||||
it("hands back an object URL once the blob lands", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["bytes"]))),
|
||||
);
|
||||
|
||||
const result = await createFencedBlobLoader().load("/api/x/y");
|
||||
|
||||
expect(result).toEqual({ kind: "loaded", url: "blob:mock/0" });
|
||||
expect(created).toEqual(["blob:mock/0"]);
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it("revokes the live URL on dispose", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["bytes"]))),
|
||||
);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
await loader.load("/api/x/y");
|
||||
loader.dispose();
|
||||
|
||||
expect(revoked).toEqual(["blob:mock/0"]);
|
||||
});
|
||||
|
||||
it("revokes a response that lands AFTER dispose instead of storing it", async () => {
|
||||
// The leak that has no visible symptom: the component unmounted, nothing
|
||||
// will ever render this, and without the check it stays alive for the life
|
||||
// of the tab.
|
||||
const { settle, fetch } = pendingResponse();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
const inFlight = loader.load("/api/x/y");
|
||||
loader.dispose();
|
||||
settle();
|
||||
|
||||
expect(await inFlight).toEqual({ kind: "superseded" });
|
||||
expect(created).toEqual(["blob:mock/0"]);
|
||||
expect(revoked).toEqual(["blob:mock/0"]);
|
||||
});
|
||||
|
||||
it("revokes a response superseded by a newer load", async () => {
|
||||
const first = pendingResponse();
|
||||
vi.stubGlobal("fetch", first.fetch);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
const stale = loader.load("/api/x/1");
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["newer"]))),
|
||||
);
|
||||
const fresh = await loader.load("/api/x/2");
|
||||
first.settle();
|
||||
|
||||
expect(await stale).toEqual({ kind: "superseded" });
|
||||
expect(fresh).toEqual({ kind: "loaded", url: "blob:mock/0" });
|
||||
// The superseded one was revoked; the live one was not.
|
||||
expect(revoked).toEqual(["blob:mock/1"]);
|
||||
expect(created).toEqual(["blob:mock/0", "blob:mock/1"]);
|
||||
});
|
||||
|
||||
it("revokes the previous URL when the same loader loads again", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Blob(["bytes"]))),
|
||||
);
|
||||
|
||||
const loader = createFencedBlobLoader();
|
||||
await loader.load("/api/x/1");
|
||||
await loader.load("/api/x/2");
|
||||
|
||||
expect(created).toEqual(["blob:mock/0", "blob:mock/1"]);
|
||||
expect(revoked).toEqual(["blob:mock/0"]);
|
||||
});
|
||||
|
||||
it("reports a refusal as failed rather than leaving the caller loading", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("no", { status: 403 })),
|
||||
);
|
||||
|
||||
const result = await createFencedBlobLoader().load("/api/x/y");
|
||||
|
||||
expect(result).toEqual({ kind: "failed" });
|
||||
// Nothing was created, so nothing needs revoking.
|
||||
expect(created).toEqual([]);
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a transport-level throw as failed too", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new TypeError("offline");
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await createFencedBlobLoader().load("/api/x/y")).toEqual({
|
||||
kind: "failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.37.0 — reach a record-fenced route from the DOM without a headerless
|
||||
* request.
|
||||
*
|
||||
* `<img src>`, `<iframe src>` and `<a href download>` are issued by the browser
|
||||
* itself. They never pass through `src/lib/api/api-fetch.ts`, so they carry no
|
||||
* record-session assertion — and once a session has entered or left a shared
|
||||
* record the fence refuses an unasserted request with 403
|
||||
* `sharing.access.denied`, permanently, because nothing about those three will
|
||||
* ever start sending a header. The visible symptom is a thumbnail that stops
|
||||
* rendering for good after somebody visits a shared record.
|
||||
*
|
||||
* So the bytes are fetched through the app transport, which attaches the
|
||||
* assertion and validates the echo, and the DOM is handed an object URL
|
||||
* instead. The shape is not new here: `document-ai-transport.ts` already
|
||||
* reaches `/api/documents/inbound/{id}/original` this way to build a `File` for
|
||||
* in-browser OCR. `src/__tests__/record-fence-headerless-transport-guard.test.ts`
|
||||
* is what keeps a future `src="/api/…"` from reintroducing the class.
|
||||
*
|
||||
* ## Why the loader is separate from the hook
|
||||
*
|
||||
* Revocation is the part worth being careful about: getting it wrong leaks a
|
||||
* decrypted health document into the tab's memory for as long as the tab lives,
|
||||
* and nothing in the UI ever shows it. The unit suite runs in `node` with no
|
||||
* DOM and no testing-library, so a hook cannot be rendered here — and an
|
||||
* untested revocation rule is exactly the kind that is wrong. The rule
|
||||
* therefore lives in {@link createFencedBlobLoader}, which is a plain object
|
||||
* with `load` and `dispose`, fully exercised in
|
||||
* `src/hooks/__tests__/use-fenced-object-url.test.ts`. The hook below is the
|
||||
* thin React lifecycle shell over it.
|
||||
*/
|
||||
export interface FencedObjectUrl {
|
||||
/** The object URL, or null while loading, disabled, or failed. */
|
||||
url: string | null;
|
||||
/** The fetch finished and did not produce bytes. Render the fallback. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** What one load produced. */
|
||||
export type FencedBlobResult =
|
||||
{ kind: "loaded"; url: string } | { kind: "failed" } | { kind: "superseded" };
|
||||
|
||||
/**
|
||||
* A one-slot object-URL holder.
|
||||
*
|
||||
* Holds at most one live URL. Loading again revokes the previous one; a
|
||||
* response that lands after `dispose()` or after a newer `load()` is revoked
|
||||
* immediately rather than stored, which is the case a fast scroll through the
|
||||
* vault timeline produces constantly — the path changes far more often than the
|
||||
* network answers.
|
||||
*/
|
||||
export function createFencedBlobLoader() {
|
||||
let current: string | null = null;
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
|
||||
const release = () => {
|
||||
if (current !== null) {
|
||||
URL.revokeObjectURL(current);
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async load(path: string): Promise<FencedBlobResult> {
|
||||
const mine = ++generation;
|
||||
release();
|
||||
let url: string | null = null;
|
||||
try {
|
||||
const res = await apiFetchRaw(path);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
url = URL.createObjectURL(blob);
|
||||
} catch {
|
||||
// Every failure reads the same to the caller — refused, offline, gone,
|
||||
// or a context mismatch the transport discarded. The caller renders its
|
||||
// fallback either way, and the fence's own reconciliation handles the
|
||||
// one case that is recoverable.
|
||||
return disposed || mine !== generation
|
||||
? { kind: "superseded" }
|
||||
: { kind: "failed" };
|
||||
}
|
||||
if (disposed || mine !== generation) {
|
||||
// Nobody is going to render this. Revoke it here or it lives as long
|
||||
// as the tab does.
|
||||
URL.revokeObjectURL(url);
|
||||
return { kind: "superseded" };
|
||||
}
|
||||
current = url;
|
||||
return { kind: "loaded", url };
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useFencedObjectUrl(path: string | null): FencedObjectUrl {
|
||||
// The resolved path is stored ALONGSIDE the result, and the return value is
|
||||
// derived by comparing it against the current one. That is what keeps this
|
||||
// free of a synchronous `setState` inside the effect: while the path has just
|
||||
// changed and the effect has not run yet, the answer is "loading" because the
|
||||
// stored result belongs to a different path — not because something reset it.
|
||||
// Without that, a fast scroll through the vault timeline would paint one
|
||||
// card's thumbnail on the next card for a frame.
|
||||
const [resolved, setResolved] = useState<{
|
||||
path: string | null;
|
||||
url: string | null;
|
||||
failed: boolean;
|
||||
}>({ path: null, url: null, failed: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (path === null) return;
|
||||
const loader = createFencedBlobLoader();
|
||||
void loader.load(path).then((result) => {
|
||||
if (result.kind === "superseded") return;
|
||||
setResolved({
|
||||
path,
|
||||
url: result.kind === "loaded" ? result.url : null,
|
||||
failed: result.kind === "failed",
|
||||
});
|
||||
});
|
||||
return () => loader.dispose();
|
||||
}, [path]);
|
||||
|
||||
if (path === null || resolved.path !== path) {
|
||||
return { url: null, failed: false };
|
||||
}
|
||||
return { url: resolved.url, failed: resolved.failed };
|
||||
}
|
||||
Reference in new issue
Block a user