From 844dc8457d6882d15d011f55dd129b5076c2f084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 18:43:13 +0200 Subject: [PATCH] feat(share-links): attach documents and serve them through the share token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//d/: 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. --- .../proxy-document-serve-framing.test.ts | 39 +++++++++ .../api/share-links/__tests__/route.test.ts | 82 ++++++++++++++++++ src/app/api/share-links/route.ts | 80 +++++++++++------ src/app/c/[token]/d/[id]/route.ts | Bin 0 -> 10634 bytes .../__tests__/exif-strip.test.ts | 31 +++++-- src/lib/validations/clinician-share-link.ts | 16 ++++ src/proxy.ts | 24 +++-- 7 files changed, 232 insertions(+), 40 deletions(-) create mode 100644 src/app/c/[token]/d/[id]/route.ts diff --git a/src/__tests__/proxy-document-serve-framing.test.ts b/src/__tests__/proxy-document-serve-framing.test.ts index 97cdb1d9c..a0353575c 100644 --- a/src/__tests__/proxy-document-serve-framing.test.ts +++ b/src/__tests__/proxy-document-serve-framing.test.ts @@ -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//d/ 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/ 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"); + } + }); +}); diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index db045fab0..2398adafd 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -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 = {}) { 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", () => { diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 1fa5d539c..94111c12a 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -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) }); diff --git a/src/app/c/[token]/d/[id]/route.ts b/src/app/c/[token]/d/[id]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e50337e5f316d84cea36a47a9ea7b5210a8633f GIT binary patch literal 10634 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 { 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")); + const itxt = pngChunk( + "iTXt", + Buffer.from("XML:com.adobe.xmp\0\0\0\0\0"), + ); 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]); diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index 01b9b8467..41fe80d54 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -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(), { diff --git a/src/proxy.ts b/src/proxy.ts index 91b71335b..07639ca09 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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//d/` is the share analogue of the owner `/original` route: + // it decrypts and serves a user-uploaded document that the clinician view + // (`/c/`) frames same-origin (an