From a930d17786ad20b9d7f299ecdca2b9aba3850af4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:43:18 +0100 Subject: [PATCH] fix: drop direction-override characters from attachment names so a sender can't fake the file type --- src/lib/__tests__/bidi.test.ts | 25 +++++++++++++++++++++++++ src/lib/__tests__/email-export.test.ts | 6 ++++++ src/lib/attachment-display.ts | 6 +++++- src/lib/bidi.ts | 12 ++++++++++++ src/lib/email-export.ts | 3 ++- 5 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/bidi.test.ts create mode 100644 src/lib/bidi.ts diff --git a/src/lib/__tests__/bidi.test.ts b/src/lib/__tests__/bidi.test.ts new file mode 100644 index 00000000..2e8bc9f9 --- /dev/null +++ b/src/lib/__tests__/bidi.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { stripBidiControls } from '../bidi'; +import { getAttachmentDisplayName } from '../attachment-display'; + +// A sender-chosen attachment name kept its direction-override characters, +// so `reportfdp.exe` showed as `reportexe.pdf`: an executable +// dressed up as a PDF (webmail lib/bidi.ts). + +const ch = (code: number) => String.fromCharCode(code); +const RLO = ch(0x202e); + +describe('direction-override characters in attachment names', () => { + it('strips the embeddings, overrides, isolates and LRM/RLM marks', () => { + const controls = [0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069].map(ch).join(''); + expect(stripBidiControls(`a${controls}b`)).toBe('ab'); + // Hebrew and Arabic letters stay. + expect(stripBidiControls('שלום.pdf')).toBe('שלום.pdf'); + }); + + it('shows the real extension in the chip name', () => { + expect(getAttachmentDisplayName(`report${RLO}fdp.exe`, 'application/octet-stream')).toBe('reportfdp.exe'); + // A name of nothing but controls falls back to the type label. + expect(getAttachmentDisplayName(RLO, 'application/pdf')).toBe('Document.pdf'); + }); +}); diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts index fd720a24..68b76c1b 100644 --- a/src/lib/__tests__/email-export.test.ts +++ b/src/lib/__tests__/email-export.test.ts @@ -246,6 +246,12 @@ describe('cachePreviewFile (in-app preview)', () => { expect(file.name).toBe('a_b_.txt'); }); + it('drops direction-override characters, so the real extension is the one the file gets shown with', async () => { + const rlo = String.fromCharCode(0x202e); + const file = await cachePreviewFile('blob-b', `report${rlo}fdp.exe`, 'application/octet-stream'); + expect(file.name).toBe('reportfdp.exe'); + }); + it('removes its folder and rethrows when the download fails', async () => { vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404')); diff --git a/src/lib/attachment-display.ts b/src/lib/attachment-display.ts index c701228e..6031c94f 100644 --- a/src/lib/attachment-display.ts +++ b/src/lib/attachment-display.ts @@ -7,6 +7,7 @@ import type { Attachment, Email } from '../api/types'; import { findCalendarAttachment } from './calendar-invitation'; import { pickEmailBody, selectRenderableHtml } from './email-body'; import { extractCidRefs } from './email-html'; +import { stripBidiControls } from './bidi'; const MIME_TYPE_LABELS: Record = { 'application/pdf': 'Document.pdf', @@ -31,7 +32,10 @@ const MIME_TYPE_LABELS: Record = { }; export function getAttachmentDisplayName(name: string | null | undefined, mimeType?: string): string { - if (name) return name; + // A direction override in a sender-chosen name can fake the file type + // (`reportfdp.exe` shows as `reportexe.pdf`). + const shown = name ? stripBidiControls(name) : ''; + if (shown) return shown; if (mimeType) { const base = mimeType.split(';')[0].trim().toLowerCase(); const label = MIME_TYPE_LABELS[base]; diff --git a/src/lib/bidi.ts b/src/lib/bidi.ts new file mode 100644 index 00000000..93bc0cd8 --- /dev/null +++ b/src/lib/bidi.ts @@ -0,0 +1,12 @@ +/** + * Unicode directional formatting characters: the explicit embeddings, + * overrides and isolates (U+202A-202E, U+2066-2069) and the LRM/RLM marks. + * In a sender-controlled name they reorder what the reader sees, so + * `reportfdp.exe` shows as `reportexe.pdf` (webmail lib/bidi.ts). + */ +const BIDI_CONTROLS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g; + +/** `text` without directional formatting characters, for display. */ +export function stripBidiControls(text: string): string { + return text.replace(BIDI_CONTROLS, ''); +} diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index c9b20160..41ebcb3c 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -14,6 +14,7 @@ import { } from './download-filename'; import { getClientCertAlias, secureFetch } from './client-cert'; import { sniffImageMime } from './email-html'; +import { stripBidiControls } from './bidi'; import { handoffMimeType } from './file-preview'; const RFC822 = 'message/rfc822'; @@ -55,7 +56,7 @@ export function attachmentSaveName(email: Email, name: string | undefined, type: function safeAttachmentName(name: string | undefined, type: string | undefined): string { const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin'; - const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim(); + const cleaned = stripBidiControls(name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim(); if (cleaned) return cleaned.slice(0, 120); return `attachment.${fallbackExt}`; }