feat(share-links): strip EXIF/XMP/GPS at the document share egress

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).
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 18:36:44 +02:00
1 parent 32f51b4299
commit 95eab66945
2 files changed
+397

No files matched your search

@@ -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 <marker> <len BE incl. the 2 len bytes> <payload>. */
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<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));
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("<x:xmpmeta>secret</x:xmpmeta>"));
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);
});
});
+228
View File
@@ -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<string> = 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 <marker> <len-hi> <len-lo> <payload…>` 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<number> = 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<string> = 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" <size LE> "WEBP"` then FourCC chunks
// (`<fourcc(4)> <size LE(4)> <payload…>`, 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]);
}