fix: drop direction-override characters from attachment names so a sender can't fake the file type

This commit is contained in:
Linus Rath
2026-10-31 03:43:18 +01:00
parent 3243714db9
commit a930d17786
5 changed files with 50 additions and 2 deletions
+25
View File
@@ -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 `report<U+202E>fdp.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');
});
});
+6
View File
@@ -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'));
+5 -1
View File
@@ -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<string, string> = {
'application/pdf': 'Document.pdf',
@@ -31,7 +32,10 @@ const MIME_TYPE_LABELS: Record<string, string> = {
};
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
// (`report<U+202E>fdp.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];
+12
View File
@@ -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
* `report<U+202E>fdp.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, '');
}
+2 -1
View File
@@ -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}`;
}