mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: drop direction-override characters from attachment names so a sender can't fake the file type
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user