From 32f51b4299482ae7570013e526dcacb4b3d342eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 18:34:58 +0200 Subject: [PATCH 1/6] feat(share-links): add frozen document-membership join (migration 0229) P3-1-T1. Introduce ClinicianShareLinkDocument, the write-once join binding a stored document to a clinician share link. The document set a share exposes is a relation, not a mutable column, so it inherits the link's frozen-scope discipline. Both foreign keys cascade so a deleted link or document takes its membership with it. --- .../0229_share_link_documents/migration.sql | 45 +++++++++++++++++++ prisma/schema.prisma | 33 +++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0229_share_link_documents/migration.sql diff --git a/prisma/migrations/0229_share_link_documents/migration.sql b/prisma/migrations/0229_share_link_documents/migration.sql new file mode 100644 index 000000000..6f7baac5d --- /dev/null +++ b/prisma/migrations/0229_share_link_documents/migration.sql @@ -0,0 +1,45 @@ +-- Document vault, Phase 3: attach hand-picked documents to a clinician share +-- link. +-- +-- `clinician_share_link_documents` — frozen, write-once membership rows +-- binding a stored document to a share link. The document set a share +-- exposes is modelled as this join, never a mutable column on the link, so +-- it inherits the link's frozen-scope discipline: rows are created in the +-- same transaction as the link, each document is owner-checked at create, +-- and the set is never widened afterwards. The public share serve route +-- serves ONLY a document id present in this set for the resolved token. +-- +-- Both foreign keys cascade: deleting the link or the underlying document +-- removes the membership cleanly (a shared document the owner later deletes +-- simply disappears from the link). +-- +-- Additive; applies clean on a 0228 database and on a fresh database as part +-- of the ordered migration chain. + +CREATE TABLE "clinician_share_link_documents" ( + "id" TEXT NOT NULL, + "share_link_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "clinician_share_link_documents_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "clinician_share_link_documents_share_link_id_document_id_key" + ON "clinician_share_link_documents" ("share_link_id", "document_id"); + +CREATE INDEX "clinician_share_link_documents_share_link_id_idx" + ON "clinician_share_link_documents" ("share_link_id"); + +CREATE INDEX "clinician_share_link_documents_document_id_idx" + ON "clinician_share_link_documents" ("document_id"); + +ALTER TABLE "clinician_share_link_documents" + ADD CONSTRAINT "clinician_share_link_documents_share_link_id_fkey" + FOREIGN KEY ("share_link_id") REFERENCES "clinician_share_links" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "clinician_share_link_documents" + ADD CONSTRAINT "clinician_share_link_documents_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62555c6c5..d0836588b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4852,13 +4852,41 @@ model ClinicianShareLink { lastAccessAt DateTime? @map("last_access_at") accessCount Int @default(0) @map("access_count") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// v1.28 — the hand-picked document set this link exposes (0..N). A relation, + /// not a column: frozen write-once at create, owner-checked, never widened. + documents ClinicianShareLinkDocument[] @@index([userId, createdAt(sort: Desc)]) @@index([expiresAt]) @@map("clinician_share_links") } +/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a +/// stored document to a clinician share link. The document set a share exposes +/// is modelled as this relation, never a mutable column: the rows are created +/// in the same transaction as the link, each `documentId` is validated as the +/// caller's own live document at create time, and the set is never widened +/// afterwards. The public share serve route serves ONLY a document id present +/// in this set for the resolved token — a guessed / foreign id is a flat 404. +/// Both foreign keys cascade: deleting the link or the underlying document +/// removes the membership cleanly (a shared document the owner later deletes +/// simply disappears from the link). +model ClinicianShareLinkDocument { + id String @id @default(cuid()) + shareLinkId String @map("share_link_id") + documentId String @map("document_id") + createdAt DateTime @default(now()) @map("created_at") + + shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + @@unique([shareLinkId, documentId]) + @@index([shareLinkId]) + @@index([documentId]) + @@map("clinician_share_link_documents") +} + // v1.11.0 W3 — period-narrative cache (Pillar P1). // // One durable, typed row per (user, period, locale) carrying the latest @@ -5815,6 +5843,9 @@ model InboundDocument { facts ExtractedFact[] conditionLinks DocumentConditionLink[] + /// v1.28 — share-link memberships (m:n via `ClinicianShareLinkDocument`). + /// Cascades: deleting this document removes it from every share it was on. + shareLinks ClinicianShareLinkDocument[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") From 95eab66945739e534585bbabbc7e5ecb5f1385df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 18:36:44 +0200 Subject: [PATCH 2/6] feat(share-links): strip EXIF/XMP/GPS at the document share egress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3-1-T3. Dependency-free metadata stripper for JPEG (drops APP1/APP13/COM), PNG (drops eXIf/tEXt/zTXt/iTXt/tIME), and WebP (drops EXIF/XMP chunks and clears the VP8X flag bits). No pixel decode, no native dependency; image data copies through verbatim. Applied on egress only — the stored original is never rewritten. Non-raster types (PDF/TIFF/HEIC/Office) and any unparseable input pass through unchanged (fail-safe). --- .../__tests__/exif-strip.test.ts | 169 +++++++++++++ src/lib/clinician-share/exif-strip.ts | 228 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/lib/clinician-share/__tests__/exif-strip.test.ts create mode 100644 src/lib/clinician-share/exif-strip.ts diff --git a/src/lib/clinician-share/__tests__/exif-strip.test.ts b/src/lib/clinician-share/__tests__/exif-strip.test.ts new file mode 100644 index 000000000..0f33b92ba --- /dev/null +++ b/src/lib/clinician-share/__tests__/exif-strip.test.ts @@ -0,0 +1,169 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; + +import { stripImageMetadata } from "@/lib/clinician-share/exif-strip"; + +/** + * The stripper walks container structure and drops metadata carriers without + * decoding pixels. These fixtures are hand-built minimal-but-valid segment + * streams so we can assert exactly which markers survive. + */ + +function u16be(n: number): Buffer { + const b = Buffer.alloc(2); + b.writeUInt16BE(n); + return b; +} +function u32be(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n); + return b; +} +function u32le(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32LE(n); + return b; +} + +/** A JPEG segment: FF . */ +function jpegSegment(marker: number, payload: Buffer): Buffer { + return Buffer.concat([ + Buffer.from([0xff, marker]), + u16be(payload.length + 2), + payload, + ]); +} + +describe("stripImageMetadata — passthrough", () => { + it("leaves non-strippable types byte-for-byte unchanged", () => { + const pdf = Buffer.from("%PDF-1.4 fake pdf body"); + expect(stripImageMetadata(pdf, "application/pdf")).toBe(pdf); + const tiff = Buffer.from([0x49, 0x49, 0x2a, 0x00, 1, 2, 3]); + expect(stripImageMetadata(tiff, "image/tiff")).toBe(tiff); + const heic = Buffer.from("ftyp-heic-fake"); + expect(stripImageMetadata(heic, "image/heic")).toBe(heic); + }); + + it("returns malformed input for the claimed type unchanged (fail-safe)", () => { + const notJpeg = Buffer.from([0x00, 0x01, 0x02, 0x03]); + expect(stripImageMetadata(notJpeg, "image/jpeg")).toBe(notJpeg); + }); +}); + +describe("stripImageMetadata — JPEG", () => { + // SOI + APP0(JFIF) + APP1(Exif w/ GPS) + APP2(ICC) + COM + DQT + SOS + scan. + const soi = Buffer.from([0xff, 0xd8]); + const app0 = jpegSegment(0xe0, Buffer.from("JFIF\0\0\0\0\0\0")); + const exifPayload = Buffer.concat([ + Buffer.from("Exif\0\0"), + // A recognisable GPS marker string we can assert is gone. + 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 app2 = jpegSegment(0xe2, iccPayload); + const com = jpegSegment(0xfe, Buffer.from("private comment")); + const dqt = jpegSegment(0xdb, Buffer.from([0x00, 0x01, 0x02, 0x03])); + const sosAndScan = Buffer.concat([ + Buffer.from([0xff, 0xda]), + u16be(2 + 6), + Buffer.from([0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]), + Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]), // entropy-coded scan bytes + ]); + const jpeg = Buffer.concat([soi, app0, app1, app2, com, dqt, sosAndScan]); + + it("removes EXIF/GPS, XMP-carrying APP1, and the comment segment", () => { + const out = stripImageMetadata(jpeg, "image/jpeg"); + expect(out.includes(Buffer.from("Exif\0\0"))).toBe(false); + expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false); + expect(out.includes(Buffer.from("private comment"))).toBe(false); + }); + + it("keeps JFIF (APP0), ICC (APP2), the quantisation table, and the scan data", () => { + const out = stripImageMetadata(jpeg, "image/jpeg"); + expect(out.subarray(0, 2)).toEqual(soi); + expect(out.includes(Buffer.from("JFIF"))).toBe(true); + expect(out.includes(Buffer.from("ICC_PROFILE"))).toBe(true); + // The DQT segment and the entropy-coded scan tail must survive verbatim. + expect(out.includes(dqt)).toBe(true); + expect(out.includes(Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]))).toBe(true); + // Output is strictly smaller (metadata removed). + expect(out.length).toBeLessThan(jpeg.length); + }); +}); + +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)]); + } + 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 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)); + const png = Buffer.concat([sig, ihdr, exif, text, itxt, gama, idat, iend]); + + it("removes eXIf, tEXt, and iTXt (XMP) chunks", () => { + const out = stripImageMetadata(png, "image/png"); + expect(out.includes(Buffer.from("GPSInfo"))).toBe(false); + expect(out.includes(Buffer.from("secret note"))).toBe(false); + expect(out.includes(Buffer.from("xmpmeta"))).toBe(false); + }); + + it("keeps IHDR, gAMA, IDAT, IEND and the signature", () => { + const out = stripImageMetadata(png, "image/png"); + expect(out.subarray(0, 8)).toEqual(sig); + expect(out.includes(gama)).toBe(true); + expect(out.includes(idat)).toBe(true); + expect(out.includes(iend)).toBe(true); + expect(out.length).toBeLessThan(png.length); + }); +}); + +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]); + } + // 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]); + const vp8x = webpChunk("VP8X", vp8xFlags); + const vp8 = webpChunk("VP8 ", Buffer.from([0x11, 0x22, 0x33, 0x44])); + const exif = webpChunk("EXIF", Buffer.from("GPSLocation private")); + const xmp = webpChunk("XMP ", Buffer.from("secret")); + const body = Buffer.concat([vp8x, vp8, exif, xmp]); + const header = Buffer.concat([ + Buffer.from("RIFF", "ascii"), + u32le(4 + body.length), + Buffer.from("WEBP", "ascii"), + ]); + const webp = Buffer.concat([header, body]); + + it("removes the EXIF and XMP chunks", () => { + const out = stripImageMetadata(webp, "image/webp"); + expect(out.includes(Buffer.from("GPSLocation"))).toBe(false); + expect(out.includes(Buffer.from("xmpmeta"))).toBe(false); + }); + + it("keeps the image chunk and clears the VP8X EXIF/XMP flag bits", () => { + const out = stripImageMetadata(webp, "image/webp"); + expect(out.includes(Buffer.from([0x11, 0x22, 0x33, 0x44]))).toBe(true); + // Locate VP8X and check its flag byte: EXIF/XMP bits cleared. + const vp8xIdx = out.indexOf(Buffer.from("VP8X", "ascii")); + expect(vp8xIdx).toBeGreaterThan(0); + const flags = out[vp8xIdx + 8]; + expect(flags & 0x08).toBe(0); + expect(flags & 0x04).toBe(0); + }); + + it("rewrites the RIFF size header to match the trimmed body", () => { + const out = stripImageMetadata(webp, "image/webp"); + expect(out.toString("ascii", 0, 4)).toBe("RIFF"); + expect(out.toString("ascii", 8, 12)).toBe("WEBP"); + // Declared size == 4 (for "WEBP") + the remaining chunk bytes. + expect(out.readUInt32LE(4)).toBe(out.length - 8); + }); +}); diff --git a/src/lib/clinician-share/exif-strip.ts b/src/lib/clinician-share/exif-strip.ts new file mode 100644 index 000000000..15c8a174e --- /dev/null +++ b/src/lib/clinician-share/exif-strip.ts @@ -0,0 +1,228 @@ +/** + * v1.28 (document vault, Phase 3) — dependency-free metadata stripper for the + * share egress boundary (P3-D4). + * + * A recipient of a shared document should not inadvertently receive the + * owner's camera EXIF / GPS / XMP metadata. This module removes those markers + * from the common camera-photo raster formats (JPEG, PNG, WebP) as the bytes + * are served through the share route — NEVER at rest. The owner's stored + * original is the artefact and is untouched (v1 §6.5): the strip is a pure + * transform applied to a decrypted copy on its way out. + * + * Pure JS, no `sharp` / `libvips` / native decode (repo guardrail G6). We do + * not decode or re-encode pixels: we walk the container's segment/chunk + * structure and drop exactly the metadata carriers, copying every image-data + * segment through verbatim. Output renders identically; only the metadata is + * gone. + * + * Honest limits (documented in the operator docs + the share copy): PDF, TIFF, + * HEIC/HEIF, and Office formats are out of this stripper's reach and pass + * through unchanged. Those leave the origin as attachment-class downloads. + * Any format we don't recognise — or any parse that runs off the end of the + * buffer — is returned byte-for-byte unchanged (fail-safe: never corrupt a + * document we can't confidently rewrite). + */ +import { Buffer } from "node:buffer"; + +/** MIME types this stripper actively rewrites. Everything else passes through. */ +const STRIPPABLE_MIMES: ReadonlySet = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +/** + * Strip EXIF/XMP/GPS metadata from a raster image's bytes, dispatched by the + * document's stored (magic-byte-sniffed) MIME type. Returns the original + * buffer unchanged for any non-strippable type, and — fail-safe — for any + * input that does not parse cleanly as the claimed format. + */ +export function stripImageMetadata(bytes: Buffer, mimeType: string): Buffer { + if (!STRIPPABLE_MIMES.has(mimeType)) return bytes; + try { + switch (mimeType) { + case "image/jpeg": + return stripJpeg(bytes); + case "image/png": + return stripPng(bytes); + case "image/webp": + return stripWebp(bytes); + default: + return bytes; + } + } catch { + // Never corrupt a document because the stripper tripped: on any parse + // error, serve the original bytes. The serving-class posture (nosniff + + // true type) is the security boundary; the strip is a privacy nicety. + return bytes; + } +} + +// ─── JPEG ──────────────────────────────────────────────────────────────────── +// +// A JPEG is `FFD8` (SOI) followed by marker segments. Each non-entropy marker +// is `FF ` where `len` counts the two +// length bytes + payload. We drop the metadata-bearing APP segments and the +// comment segment, copy every other segment verbatim, and once we hit SOS +// (`FFDA`) copy the rest of the file (entropy-coded scan data) unchanged. +// +// Dropped: APP1 (`FFE1`, Exif + XMP), APP13 (`FFED`, Photoshop / IPTC), and +// COM (`FFFE`, free-text comment). Kept: APP0 (`FFE0`, JFIF — some decoders +// expect it) and APP2 (`FFE2`, ICC colour profile — dropping it would shift +// colours), plus every structural segment (quantisation/Huffman tables, frame +// headers, …). + +const JPEG_DROP_MARKERS: ReadonlySet = new Set([ + 0xe1, // APP1 — Exif, XMP + 0xed, // APP13 — Photoshop / IPTC-IIM + 0xfe, // COM — comment +]); + +function stripJpeg(bytes: Buffer): Buffer { + if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + return bytes; // not a JPEG SOI — pass through + } + const out: Buffer[] = [bytes.subarray(0, 2)]; // SOI + let offset = 2; + while (offset + 1 < bytes.length) { + if (bytes[offset] !== 0xff) { + // Misaligned — bail and keep the remainder verbatim (fail-safe). + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + const marker = bytes[offset + 1]; + // Start Of Scan: everything after is entropy-coded image data. Copy it all. + if (marker === 0xda) { + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + // Standalone markers without a length payload (RSTn, TEM) — copy the 2 + // marker bytes and continue. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + out.push(bytes.subarray(offset, offset + 2)); + offset += 2; + continue; + } + // Length-prefixed segment. + if (offset + 4 > bytes.length) { + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + const segLen = bytes.readUInt16BE(offset + 2); + const segEnd = offset + 2 + segLen; + if (segLen < 2 || segEnd > bytes.length) { + // Corrupt length — keep the remainder verbatim rather than lose data. + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + if (!JPEG_DROP_MARKERS.has(marker)) { + out.push(bytes.subarray(offset, segEnd)); + } + offset = segEnd; + } + return Buffer.concat(out); +} + +// ─── PNG ───────────────────────────────────────────────────────────────────── +// +// An 8-byte signature followed by `length(4 BE) type(4 ascii) data crc(4)` +// chunks. We drop the ancillary metadata chunks and copy everything else +// (IHDR, PLTE, IDAT, IEND, gAMA, cHRM, iCCP, sRGB, …) verbatim. +// +// Dropped: `eXIf` (EXIF), `tEXt` / `zTXt` / `iTXt` (text incl. XMP), `tIME` +// (last-modified timestamp). + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const PNG_DROP_CHUNKS: ReadonlySet = new Set([ + "eXIf", + "tEXt", + "zTXt", + "iTXt", + "tIME", +]); + +function stripPng(bytes: Buffer): Buffer { + if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) { + return bytes; + } + const out: Buffer[] = [bytes.subarray(0, 8)]; + let offset = 8; + while (offset + 8 <= bytes.length) { + const dataLen = bytes.readUInt32BE(offset); + const type = bytes.toString("ascii", offset + 4, offset + 8); + const chunkEnd = offset + 12 + dataLen; // len + type + data + crc + if (chunkEnd > bytes.length) { + // Truncated chunk — keep the remainder verbatim (fail-safe). + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + if (!PNG_DROP_CHUNKS.has(type)) { + out.push(bytes.subarray(offset, chunkEnd)); + } + offset = chunkEnd; + if (type === "IEND") break; // nothing legitimate follows IEND + } + return Buffer.concat(out); +} + +// ─── WebP ──────────────────────────────────────────────────────────────────── +// +// A RIFF container: `"RIFF" "WEBP"` then FourCC chunks +// (` `, payload padded to an even length). +// We drop the `EXIF` and `XMP ` chunks and, when the extended header `VP8X` is +// present, clear its EXIF/XMP presence flag bits so the rewritten file is +// self-consistent. The image-data chunks (VP8/VP8L/VP8X/ALPH/ANIM/ANMF) copy +// through verbatim. + +function stripWebp(bytes: Buffer): Buffer { + if ( + bytes.length < 12 || + bytes.toString("ascii", 0, 4) !== "RIFF" || + bytes.toString("ascii", 8, 12) !== "WEBP" + ) { + return bytes; + } + const kept: Buffer[] = []; + let offset = 12; + while (offset + 8 <= bytes.length) { + const fourcc = bytes.toString("ascii", offset, offset + 4); + const chunkSize = bytes.readUInt32LE(offset + 4); + const padded = chunkSize + (chunkSize % 2); // chunks pad to even length + const payloadStart = offset + 8; + const chunkEnd = payloadStart + padded; + if (chunkEnd > bytes.length) { + // Truncated — keep the remainder verbatim (fail-safe). + kept.push(bytes.subarray(offset)); + offset = bytes.length; + break; + } + if (fourcc === "EXIF" || fourcc === "XMP ") { + // Drop the metadata chunk entirely. + offset = chunkEnd; + continue; + } + if (fourcc === "VP8X") { + // Extended-format header: byte 0 of the payload carries the feature + // flags. Clear the EXIF (0x08) and XMP (0x04) presence bits so the + // header no longer advertises metadata we just removed. + const chunk = Buffer.from(bytes.subarray(offset, chunkEnd)); + chunk[8] = chunk[8] & ~0x08 & ~0x04; + kept.push(chunk); + offset = chunkEnd; + continue; + } + kept.push(bytes.subarray(offset, chunkEnd)); + offset = chunkEnd; + } + // Rebuild the RIFF header with the recomputed payload size (the sum of the + // kept chunks plus the trailing "WEBP" FourCC). + const body = Buffer.concat(kept); + const header = Buffer.alloc(12); + header.write("RIFF", 0, "ascii"); + header.writeUInt32LE(4 + body.length, 4); // "WEBP" + chunk bytes + header.write("WEBP", 8, "ascii"); + return Buffer.concat([header, body]); +} 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 3/6] 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