mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(share-links): attach documents and serve them through the share token
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded documentIds set; validate each id as the caller's own live document and create the membership rows in the same transaction (frozen write-once). Summary DTOs gain documentCount (never ids, never bytes). Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved (never requireAuth), passphrase-gated via the token-scoped unlock cookie, revocation/expiry re-checked before any decrypt, confined to the link's frozen document set (foreign/deleted id → flat 404), fail-closed decrypt, identical serving-class posture as the owner route (inline Class A / attachment Class B, nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate limit. Carve the serve path out of the proxy's blanket DENY posture with the same document CSP as the owner route (default-src 'none'; frame-ancestors 'self'), anchored to exactly two dynamic segments so no lookalike over-matches.
This commit is contained in:
1 parent
95eab66945
commit
844dc8457d
7 files changed
+232
-40
No files matched your search
@@ -86,3 +86,42 @@ describe("proxy.ts document serve-route framing carve-out", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts share document serve-route framing carve-out", () => {
|
||||
const shareToken = "hls_000000000000000000000000000000000000000000000000";
|
||||
|
||||
it("allows same-origin framing on the /c/<token>/d/<id> serve path", () => {
|
||||
const res = proxy(makeRequest(`/c/${shareToken}/d/doc123`));
|
||||
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toContain("frame-ancestors 'self'");
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
// No sandbox (Chromium downloads sandboxed PDFs) and no script sources.
|
||||
expect(csp).not.toMatch(/sandbox/);
|
||||
expect(csp).not.toMatch(/script-src/);
|
||||
});
|
||||
|
||||
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
|
||||
const res = proxy(makeRequest(`/c/${shareToken}`));
|
||||
// The clinician view is a PAGE — it must not be framable and keeps the
|
||||
// page CSP; only the blob subresource under it takes the serve posture.
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match nested, suffixed, or lookalike share serve paths", () => {
|
||||
for (const path of [
|
||||
`/c/${shareToken}/d/doc123/extra`, // extra trailing segment
|
||||
`/c/${shareToken}/d/doc123x/more/deep`, // deep suffix
|
||||
`/c/${shareToken}/dx/doc123`, // wrong segment name
|
||||
`/c/${shareToken}/d`, // missing id
|
||||
`/c/${shareToken}/d/`, // empty id
|
||||
"/c//d/doc123", // empty token (traversal-ish)
|
||||
]) {
|
||||
const res = proxy(makeRequest(path));
|
||||
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
inboundDocument: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -81,6 +84,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
revokedAt: null,
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
_count: { documents: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -178,6 +182,84 @@ describe("POST /api/share-links — create", () => {
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches own live documents in the create transaction (frozen write-once)", async () => {
|
||||
// The ownership probe returns exactly the requested (deduped) ids as live.
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
{ id: "doc-b" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ _count: { documents: 2 } }) as never,
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
postReq(validBody({ documentIds: ["doc-a", "doc-b", "doc-a"] })),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(2);
|
||||
|
||||
// Ownership was checked against the caller's own LIVE documents.
|
||||
const probeArg = vi.mocked(prisma.inboundDocument.findMany).mock
|
||||
.calls[0][0];
|
||||
expect(probeArg!.where).toEqual({
|
||||
id: { in: ["doc-a", "doc-b"] }, // deduped
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
// Membership rows are created NESTED in the same create call (one txn),
|
||||
// deduped, never widened afterwards.
|
||||
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
||||
.calls[0][0];
|
||||
expect(createArg.data.documents).toEqual({
|
||||
create: [{ documentId: "doc-a" }, { documentId: "doc-b" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a foreign / deleted document id at create (422, no link minted)", async () => {
|
||||
// Only one of the two requested ids resolves as the caller's own live doc.
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq(validBody({ documentIds: ["doc-a", "not-mine"] })),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
// No share is ever minted with a document the caller does not own (B5).
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects more than the document cap via strict schema (422)", async () => {
|
||||
const tooMany = Array.from({ length: 51 }, (_, i) => `doc-${i}`);
|
||||
const res = await POST(postReq(validBody({ documentIds: tooMany })));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a documents-only share (empty report sections)", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
documentIds: ["doc-a"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/share-links — list", () => {
|
||||
|
||||
@@ -54,6 +54,27 @@ function resolveOrigin(request: NextRequest): string {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row selection every owner-facing projection reads. `_count.documents`
|
||||
* surfaces the size of the frozen document set without ever loading the ids
|
||||
* (let alone the blobs) — the metadata-only discipline (P3-D5).
|
||||
*/
|
||||
const shareLinkSummarySelect = {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
_count: { select: { documents: true } },
|
||||
} as const;
|
||||
|
||||
/** Project a stored row to the safe owner-facing shape (never the token). */
|
||||
function toSummary(row: {
|
||||
id: string;
|
||||
@@ -68,6 +89,7 @@ function toSummary(row: {
|
||||
revokedAt: Date | null;
|
||||
lastAccessAt: Date | null;
|
||||
accessCount: number;
|
||||
_count: { documents: number };
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -79,6 +101,9 @@ function toSummary(row: {
|
||||
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
||||
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
||||
protected: row.passphraseHash !== null,
|
||||
// v1.28 — how many documents this share carries (never the ids, never the
|
||||
// bytes; the serve route is the only decrypt path).
|
||||
documentCount: row._count.documents,
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
|
||||
@@ -120,6 +145,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rawPassphrase = generatePassphrase();
|
||||
const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
|
||||
|
||||
// v1.28 — resolve the frozen document set. The ids are deduped (a client
|
||||
// could repeat one, which the unique membership index would otherwise
|
||||
// reject) and then validated as the caller's OWN live documents. A single
|
||||
// foreign / deleted / unknown id seals the whole create as 422 — no share is
|
||||
// ever minted with a document the caller does not own (B5). The rows are
|
||||
// created in the SAME transaction as the link (nested create), so the
|
||||
// membership is frozen write-once atomically with the scope columns.
|
||||
const documentIds = Array.from(new Set(input.documentIds ?? []));
|
||||
if (documentIds.length > 0) {
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
where: { id: { in: documentIds }, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (owned.length !== documentIds.length) {
|
||||
return apiError("One or more documents are not available to share", 422, {
|
||||
errorCode: "share-link.documents.invalid",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field build — no mass assignment. Scope columns are written
|
||||
// exactly once here and never updated.
|
||||
const created = await prisma.clinicianShareLink.create({
|
||||
@@ -134,21 +179,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
resourceTypes: input.resourceTypes ?? [],
|
||||
allowFhirApi: input.allowFhirApi ?? false,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
documentIds.length > 0
|
||||
? { create: documentIds.map((documentId) => ({ documentId })) }
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
},
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
await auditLog("share-link.create", {
|
||||
@@ -158,6 +194,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
shareLinkId: created.id,
|
||||
resourceTypes: created.resourceTypes,
|
||||
allowFhirApi: created.allowFhirApi,
|
||||
documentCount: created._count.documents,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
});
|
||||
@@ -188,20 +225,7 @@ export const GET = apiHandler(async () => {
|
||||
const rows = await prisma.clinicianShareLink.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
},
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
return apiSuccess({ shareLinks: rows.map(toSummary) });
|
||||
|
||||
Binary file not shown.
@@ -60,7 +60,10 @@ describe("stripImageMetadata — JPEG", () => {
|
||||
Buffer.from("GPSLatitude=48.137 GPSLongitude=11.575"),
|
||||
]);
|
||||
const app1 = jpegSegment(0xe1, exifPayload);
|
||||
const iccPayload = Buffer.concat([Buffer.from("ICC_PROFILE\0"), Buffer.from([1, 2, 3, 4])]);
|
||||
const iccPayload = Buffer.concat([
|
||||
Buffer.from("ICC_PROFILE\0"),
|
||||
Buffer.from([1, 2, 3, 4]),
|
||||
]);
|
||||
const app2 = jpegSegment(0xe2, iccPayload);
|
||||
const com = jpegSegment(0xfe, Buffer.from("private comment"));
|
||||
const dqt = jpegSegment(0xdb, Buffer.from([0x00, 0x01, 0x02, 0x03]));
|
||||
@@ -95,12 +98,23 @@ describe("stripImageMetadata — JPEG", () => {
|
||||
describe("stripImageMetadata — PNG", () => {
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
return Buffer.concat([u32be(data.length), Buffer.from(type, "ascii"), data, u32be(0)]);
|
||||
return Buffer.concat([
|
||||
u32be(data.length),
|
||||
Buffer.from(type, "ascii"),
|
||||
data,
|
||||
u32be(0),
|
||||
]);
|
||||
}
|
||||
const ihdr = pngChunk("IHDR", Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]));
|
||||
const ihdr = pngChunk(
|
||||
"IHDR",
|
||||
Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]),
|
||||
);
|
||||
const exif = pngChunk("eXIf", Buffer.from("GPSInfo private-location"));
|
||||
const text = pngChunk("tEXt", Buffer.from("Comment\0secret note"));
|
||||
const itxt = pngChunk("iTXt", Buffer.from("XML:com.adobe.xmp\0\0\0\0\0<x:xmpmeta/>"));
|
||||
const itxt = pngChunk(
|
||||
"iTXt",
|
||||
Buffer.from("XML:com.adobe.xmp\0\0\0\0\0<x:xmpmeta/>"),
|
||||
);
|
||||
const gama = pngChunk("gAMA", Buffer.from([0, 0, 0x8a, 0x3d]));
|
||||
const idat = pngChunk("IDAT", Buffer.from([0x78, 0x9c, 0x01, 0x00]));
|
||||
const iend = pngChunk("IEND", Buffer.alloc(0));
|
||||
@@ -125,8 +139,13 @@ describe("stripImageMetadata — PNG", () => {
|
||||
|
||||
describe("stripImageMetadata — WebP", () => {
|
||||
function webpChunk(fourcc: string, data: Buffer): Buffer {
|
||||
const padded = data.length % 2 === 1 ? Buffer.concat([data, Buffer.from([0])]) : data;
|
||||
return Buffer.concat([Buffer.from(fourcc, "ascii"), u32le(data.length), padded]);
|
||||
const padded =
|
||||
data.length % 2 === 1 ? Buffer.concat([data, Buffer.from([0])]) : data;
|
||||
return Buffer.concat([
|
||||
Buffer.from(fourcc, "ascii"),
|
||||
u32le(data.length),
|
||||
padded,
|
||||
]);
|
||||
}
|
||||
// VP8X with EXIF (0x08) + XMP (0x04) flags set, an image chunk, plus EXIF/XMP.
|
||||
const vp8xFlags = Buffer.from([0x08 | 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
|
||||
@@ -33,10 +33,22 @@ export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);
|
||||
|
||||
const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.28 — the most documents a single share may carry. A share is a hand-picked
|
||||
* handover, not a bulk dump; the cap bounds the create-time ownership check and
|
||||
* the public view's per-blob fetch fan-out. The ids are validated as the
|
||||
* caller's own LIVE documents in the route (a schema can only bound the shape).
|
||||
*/
|
||||
export const SHARE_LINK_MAX_DOCUMENTS = 50;
|
||||
|
||||
/**
|
||||
* Create payload. `expiresAt` is an absolute ISO instant, must be in the
|
||||
* future, and at most `SHARE_LINK_MAX_DAYS` ahead of now. `rangeStart` /
|
||||
* `rangeEnd` are the frozen reporting window (rangeEnd null = rolling).
|
||||
* `documentIds` (optional) is the hand-picked document set, frozen write-once
|
||||
* at create; each id is ownership-checked in the route before any row is
|
||||
* written. A documents-only share (empty report sections + a non-empty
|
||||
* `documentIds`) is valid.
|
||||
*/
|
||||
export const createShareLinkSchema = z
|
||||
.object({
|
||||
@@ -46,6 +58,10 @@ export const createShareLinkSchema = z
|
||||
sections: exportSectionsSchema.optional(),
|
||||
resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(),
|
||||
allowFhirApi: z.boolean().optional(),
|
||||
documentIds: z
|
||||
.array(z.string().trim().min(1).max(64))
|
||||
.max(SHARE_LINK_MAX_DOCUMENTS)
|
||||
.optional(),
|
||||
expiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((v) => new Date(v).getTime() > Date.now(), {
|
||||
|
||||
+18
-6
@@ -313,12 +313,23 @@ export function proxy(request: NextRequest) {
|
||||
const isDocumentServeRoute =
|
||||
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
|
||||
|
||||
// v1.28 — the public share-scoped document serve route at
|
||||
// `/c/<token>/d/<id>` is the share analogue of the owner `/original` route:
|
||||
// it decrypts and serves a user-uploaded document that the clinician view
|
||||
// (`/c/<token>`) frames same-origin (an <iframe> for inline-class PDFs). It
|
||||
// needs the SAME narrow posture — SAMEORIGIN framing + a document CSP — and
|
||||
// NOT the `/c/` page CSP, which pins `frame-ancestors 'none'` and would
|
||||
// refuse the preview embed. Anchored to EXACTLY two dynamic segments so a
|
||||
// `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a `/c/<token>/dx/<id>`
|
||||
// lookalike never inherits the carve-out.
|
||||
const isShareDocumentServeRoute = /^\/c\/[^/]+\/d\/[^/]+$/.test(pathname);
|
||||
|
||||
// Both serve routes take the narrow, document-only header posture.
|
||||
const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute;
|
||||
|
||||
// Security headers
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set(
|
||||
"X-Frame-Options",
|
||||
isDocumentServeRoute ? "SAMEORIGIN" : "DENY",
|
||||
);
|
||||
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
@@ -405,8 +416,9 @@ export function proxy(request: NextRequest) {
|
||||
// page-load. Avatars now live on the User row and serve from
|
||||
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
|
||||
// them.
|
||||
const csp = isDocumentServeRoute
|
||||
? // Vault serve route (see the X-Frame-Options note above): the
|
||||
const csp = isServeRoute
|
||||
? // Vault serve route (owner `/original` OR share `/c/<token>/d/<id>`; see
|
||||
// the X-Frame-Options note above): the
|
||||
// response is a user-uploaded document, not an app page.
|
||||
// `default-src 'none'` — a served document may load nothing as if
|
||||
// it were a page — and `frame-ancestors 'self'` permits exactly the
|
||||
|
||||
Reference in new issue
Block a user