mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in new issue
Block a user