Files
cinny/src/app/utils/stripImageMetadata.test.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

126 lines
4.5 KiB
TypeScript

/* eslint-disable no-bitwise -- byte-level container parsing */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildOrientationApp1,
readJpegOrientation,
stripJpeg,
stripPng,
stripWebp,
stripImageMetadataBytes,
} from './stripImageMetadata';
const here = path.dirname(fileURLToPath(import.meta.url));
const fixture = (name: string) =>
new Uint8Array(fs.readFileSync(path.join(here, '__fixtures__', name)));
const has = (bytes: Uint8Array, text: string) =>
Buffer.from(bytes).includes(Buffer.from(text, 'latin1'));
/** Walk JPEG segments up to SOS; returns marker list (sanity: structure intact). */
const jpegMarkers = (b: Uint8Array): string[] => {
const out: string[] = [];
let pos = 2;
while (pos + 4 <= b.length && b[pos] === 0xff) {
const m = b[pos + 1];
out.push(m.toString(16));
if (m === 0xda) break;
pos += 2 + ((b[pos + 2] << 8) | b[pos + 3]);
}
return out;
};
describe('stripJpeg', () => {
it('removes GPS, model and timestamp, keeps a valid JPEG', () => {
const src = fixture('gps.jpg');
assert.ok(has(src, 'LotusPhone 9'));
const { out, stripped } = stripJpeg(src);
assert.equal(stripped, true);
assert.ok(!has(out, 'LotusPhone 9'));
assert.ok(!has(out, '2026:09:20'));
assert.ok(out.length < src.length);
assert.deepEqual([out[0], out[1]], [0xff, 0xd8]);
assert.deepEqual([out[out.length - 2], out[out.length - 1]], [0xff, 0xd9]);
assert.ok(jpegMarkers(out).includes('da'));
// Pixel data untouched: everything from SOS on is byte-identical.
const sos = (b: Uint8Array) => Buffer.from(b).indexOf(Buffer.from([0xff, 0xda]));
assert.deepEqual(Buffer.from(out.subarray(sos(out))), Buffer.from(src.subarray(sos(src))));
});
it('keeps a non-default orientation in a minimal EXIF block', () => {
const { out, orientation } = stripJpeg(fixture('gps.jpg'));
assert.equal(orientation, 6);
const markers = jpegMarkers(out);
assert.ok(markers.includes('e1'));
// Find the APP1 we wrote and read the orientation back.
let pos = 2;
let found: number | undefined;
while (pos + 4 <= out.length && out[pos] === 0xff) {
const len = (out[pos + 2] << 8) | out[pos + 3];
if (out[pos + 1] === 0xe1) {
found = readJpegOrientation(out.subarray(pos + 4, pos + 2 + len));
assert.equal(len, buildOrientationApp1(6).length - 2);
}
if (out[pos + 1] === 0xda) break;
pos += 2 + len;
}
assert.equal(found, 6);
assert.ok(!has(out, 'LotusPhone 9'));
});
it('drops EXIF and the comment segment when orientation is 1 (no EXIF written back)', () => {
const src = fixture('plain.jpg');
assert.ok(has(src, 'made with love'));
const { out, stripped, orientation } = stripJpeg(src);
assert.equal(stripped, true);
assert.equal(orientation, undefined);
assert.ok(!has(out, 'made with love'));
assert.ok(!has(out, 'Cam'));
assert.ok(!jpegMarkers(out).includes('e1'));
});
it('returns the input untouched when there is nothing to strip', () => {
const src = fixture('clean.jpg');
const { out, stripped } = stripJpeg(src);
assert.equal(stripped, false);
assert.equal(out, src);
});
});
describe('stripPng', () => {
it('drops eXIf and text chunks, keeps IHDR/IDAT/IEND', () => {
const src = fixture('meta.png');
assert.ok(has(src, 'xmpmeta'));
const { out, stripped } = stripPng(src);
assert.equal(stripped, true);
assert.ok(!has(out, 'xmpmeta'));
assert.ok(!has(out, 'LotusPhone'));
assert.ok(!has(out, 'eXIf'));
assert.ok(has(out, 'IHDR') && has(out, 'IDAT') && has(out, 'IEND'));
});
});
describe('stripWebp', () => {
it('drops the EXIF chunk, clears the VP8X flag and fixes the RIFF size', () => {
const src = fixture('meta.webp');
assert.ok(has(src, 'LotusPhone'));
const { out, stripped } = stripWebp(src);
assert.equal(stripped, true);
assert.ok(!has(out, 'LotusPhone'));
assert.ok(!has(out, 'EXIF'));
const riff = out[4] | (out[5] << 8) | (out[6] << 16) | (out[7] << 24);
assert.equal(riff, out.length - 8);
const vp8x = Buffer.from(out).indexOf(Buffer.from('VP8X'));
if (vp8x !== -1) assert.equal(out[vp8x + 8] & 0x0c, 0);
});
});
describe('stripImageMetadataBytes', () => {
it('passes unknown types through', () => {
const b = new Uint8Array([1, 2, 3]);
assert.deepEqual(stripImageMetadataBytes(b, 'image/gif'), { out: b, stripped: false });
});
});