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] 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]); +}