feat(privacy): strip EXIF/XMP/IPTC from image uploads by default (#109)
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

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
This commit is contained in:
2026-09-20 14:22:34 -04:00
co-authored by Claude Opus 5
parent 6363939654
commit be2c202543
16 changed files with 465 additions and 12 deletions
+5 -2
View File
@@ -15,6 +15,8 @@ import {
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtomValue, useStore } from 'jotai';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -53,6 +55,7 @@ export function Share() {
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
useEffect(() => {
readSharedPayload()
@@ -80,7 +83,7 @@ export function Share() {
if (shared.files.length) {
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
type: 'PUT',
item: await filesToUploadItems(room, shared.files),
item: await filesToUploadItems(room, shared.files, stripImageMetadata),
});
}
const text = shareDraftText(shared.payload);
@@ -95,7 +98,7 @@ export function Share() {
setBusy(false);
}
},
[shared, busy, navigateRoom, store],
[shared, busy, navigateRoom, store, stripImageMetadata],
);
return (