Files
cinny/src/app/utils/stripImageMetadata.ts
T
jaredandClaude Opus 5 be2c202543
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
feat(privacy): strip EXIF/XMP/IPTC from image uploads by default (#109)
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.

Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".

Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:22:34 -04:00

251 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable no-bitwise -- byte-level container parsing */
/**
* [Gitea #109] Remove EXIF / XMP / IPTC metadata from an image WITHOUT
* re-encoding its pixels, so phone photos stop carrying GPS coordinates,
* device model and timestamps into the room. JPEG, PNG and WebP are handled
* at the container level; anything else is returned untouched.
*
* JPEG orientation: a bare strip would un-rotate photos whose pixels are
* stored sideways, so when the EXIF Orientation tag is not 1 a minimal EXIF
* block carrying only that tag is written back.
*/
export type StripResult = {
blob: Blob;
/** Whether any metadata segment was actually removed. */
stripped: boolean;
/** EXIF orientation that was preserved (JPEG only), if any. */
orientation?: number;
};
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
/** Read the EXIF Orientation tag (1–8) from an APP1 payload, or undefined. */
export function readJpegOrientation(app1: Uint8Array): number | undefined {
if (app1.length < 14) return undefined;
for (let i = 0; i < 6; i += 1) if (app1[i] !== EXIF_HEADER[i]) return undefined;
const tiff = 6;
const le = app1[tiff] === 0x49 && app1[tiff + 1] === 0x49;
const view = new DataView(app1.buffer, app1.byteOffset, app1.byteLength);
const u16 = (o: number) => view.getUint16(o, le);
const u32 = (o: number) => view.getUint32(o, le);
if (u16(tiff + 2) !== 0x2a) return undefined;
const ifd0 = tiff + u32(tiff + 4);
if (ifd0 + 2 > app1.length) return undefined;
const count = u16(ifd0);
for (let i = 0; i < count; i += 1) {
const entry = ifd0 + 2 + i * 12;
if (entry + 12 > app1.length) return undefined;
if (u16(entry) === 0x0112) {
const value = u16(entry + 8);
return value >= 1 && value <= 8 ? value : undefined;
}
}
return undefined;
}
/** A minimal APP1 segment: EXIF header + TIFF + one IFD0 entry (Orientation). */
export function buildOrientationApp1(orientation: number): Uint8Array {
// TIFF (big-endian): "MM", 0x002A, IFD offset 8; IFD0: count 1, entry, next 0.
const tiff = new Uint8Array([
0x4d,
0x4d,
0x00,
0x2a,
0x00,
0x00,
0x00,
0x08,
0x00,
0x01,
0x01,
0x12,
0x00,
0x03,
0x00,
0x00,
0x00,
0x01,
0x00,
orientation & 0xff,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
]);
const payload = new Uint8Array(EXIF_HEADER.length + tiff.length);
payload.set(EXIF_HEADER, 0);
payload.set(tiff, EXIF_HEADER.length);
const seg = new Uint8Array(4 + payload.length);
seg[0] = 0xff;
seg[1] = 0xe1;
const len = payload.length + 2;
seg[2] = (len >> 8) & 0xff;
seg[3] = len & 0xff;
seg.set(payload, 4);
return seg;
}
/** JPEG: drop APP1 (EXIF/XMP), APP13 (IPTC), COM; keep JFIF/ICC and the image. */
export function stripJpeg(bytes: Uint8Array): {
out: Uint8Array;
stripped: boolean;
orientation?: number;
} {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
return { out: bytes, stripped: false };
const parts: Uint8Array[] = [bytes.subarray(0, 2)];
let pos = 2;
let stripped = false;
let orientation: number | undefined;
let insertedOrientation = false;
while (pos + 4 <= bytes.length) {
if (bytes[pos] !== 0xff) break;
const marker = bytes[pos + 1];
// Start of scan: everything from here to the end is entropy-coded data.
if (marker === 0xda) break;
const len = (bytes[pos + 2] << 8) | bytes[pos + 3];
const segEnd = pos + 2 + len;
if (len < 2 || segEnd > bytes.length) break;
const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;
if (drop) {
stripped = true;
if (marker === 0xe1 && orientation === undefined) {
orientation = readJpegOrientation(bytes.subarray(pos + 4, segEnd));
}
} else {
parts.push(bytes.subarray(pos, segEnd));
}
pos = segEnd;
// Put the orientation-only EXIF right after SOI/JFIF, before anything else.
if (!insertedOrientation && orientation !== undefined && orientation !== 1 && marker !== 0xe0) {
insertedOrientation = true;
parts.push(buildOrientationApp1(orientation));
}
}
if (!stripped) return { out: bytes, stripped: false };
if (orientation !== undefined && orientation !== 1 && !insertedOrientation) {
parts.push(buildOrientationApp1(orientation));
}
parts.push(bytes.subarray(pos));
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let o = 0;
parts.forEach((p) => {
out.set(p, o);
o += p.length;
});
return { out, stripped, orientation: orientation !== 1 ? orientation : undefined };
}
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const PNG_DROP = new Set(['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']);
/** PNG: drop eXIf and text chunks (XMP lives in iTXt); keep colour chunks. */
export function stripPng(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } {
if (bytes.length < 8) return { out: bytes, stripped: false };
for (let i = 0; i < 8; i += 1)
if (bytes[i] !== PNG_SIG[i]) return { out: bytes, stripped: false };
const parts: Uint8Array[] = [bytes.subarray(0, 8)];
let pos = 8;
let stripped = false;
while (pos + 8 <= bytes.length) {
const len =
((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
const type = String.fromCharCode(
bytes[pos + 4],
bytes[pos + 5],
bytes[pos + 6],
bytes[pos + 7],
);
const end = pos + 12 + len;
if (end > bytes.length) break;
if (PNG_DROP.has(type)) stripped = true;
else parts.push(bytes.subarray(pos, end));
pos = end;
}
if (!stripped) return { out: bytes, stripped: false };
parts.push(bytes.subarray(pos));
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let o = 0;
parts.forEach((p) => {
out.set(p, o);
o += p.length;
});
return { out, stripped };
}
const fourcc = (b: Uint8Array, o: number) =>
String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]);
/** WebP: drop EXIF/XMP chunks and clear their VP8X flags; fix the RIFF size. */
export function stripWebp(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } {
if (bytes.length < 12 || fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'WEBP') {
return { out: bytes, stripped: false };
}
const chunks: Uint8Array[] = [];
let pos = 12;
let stripped = false;
let vp8x: Uint8Array | undefined;
while (pos + 8 <= bytes.length) {
const id = fourcc(bytes, pos);
const len =
(bytes[pos + 4] | (bytes[pos + 5] << 8) | (bytes[pos + 6] << 16) | (bytes[pos + 7] << 24)) >>>
0;
const end = pos + 8 + len + (len % 2);
if (pos + 8 + len > bytes.length) break;
if (id === 'EXIF' || id === 'XMP ') stripped = true;
else {
const chunk = bytes.slice(pos, Math.min(end, bytes.length));
if (id === 'VP8X') vp8x = chunk;
chunks.push(chunk);
}
pos = end;
}
if (!stripped) return { out: bytes, stripped: false };
// VP8X flags byte: bit 3 = EXIF, bit 2 = XMP (0x08 / 0x04).
if (vp8x && vp8x.length >= 9) vp8x[8] &= ~0x0c;
const body = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(12 + body);
out.set(bytes.subarray(0, 12), 0);
const riffSize = 4 + body;
out[4] = riffSize & 0xff;
out[5] = (riffSize >> 8) & 0xff;
out[6] = (riffSize >> 16) & 0xff;
out[7] = (riffSize >> 24) & 0xff;
let o = 12;
chunks.forEach((c) => {
out.set(c, o);
o += c.length;
});
return { out, stripped };
}
export const STRIPPABLE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export function stripImageMetadataBytes(
bytes: Uint8Array,
mimeType: string,
): { out: Uint8Array; stripped: boolean; orientation?: number } {
if (mimeType === 'image/jpeg') return stripJpeg(bytes);
if (mimeType === 'image/png') return stripPng(bytes);
if (mimeType === 'image/webp') return stripWebp(bytes);
return { out: bytes, stripped: false };
}
/** Strip a File/Blob; returns the same object when nothing changed or the type is unsupported. */
export async function stripImageMetadata(file: File): Promise<StripResult & { file: File }> {
if (!STRIPPABLE_TYPES.includes(file.type)) return { blob: file, file, stripped: false };
const bytes = new Uint8Array(await file.arrayBuffer());
const { out, stripped, orientation } = stripImageMetadataBytes(bytes, file.type);
if (!stripped) return { blob: file, file, stripped: false };
const next = new File([out as BlobPart], file.name, {
type: file.type,
lastModified: file.lastModified,
});
return { blob: next, file: next, stripped: true, orientation };
}