mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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');
|
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 () => {
|
it('removes its folder and rethrows when the download fails', async () => {
|
||||||
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
|
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 { findCalendarAttachment } from './calendar-invitation';
|
||||||
import { pickEmailBody, selectRenderableHtml } from './email-body';
|
import { pickEmailBody, selectRenderableHtml } from './email-body';
|
||||||
import { extractCidRefs } from './email-html';
|
import { extractCidRefs } from './email-html';
|
||||||
|
import { stripBidiControls } from './bidi';
|
||||||
|
|
||||||
const MIME_TYPE_LABELS: Record<string, string> = {
|
const MIME_TYPE_LABELS: Record<string, string> = {
|
||||||
'application/pdf': 'Document.pdf',
|
'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 {
|
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) {
|
if (mimeType) {
|
||||||
const base = mimeType.split(';')[0].trim().toLowerCase();
|
const base = mimeType.split(';')[0].trim().toLowerCase();
|
||||||
const label = MIME_TYPE_LABELS[base];
|
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';
|
} from './download-filename';
|
||||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||||
import { sniffImageMime } from './email-html';
|
import { sniffImageMime } from './email-html';
|
||||||
|
import { stripBidiControls } from './bidi';
|
||||||
import { handoffMimeType } from './file-preview';
|
import { handoffMimeType } from './file-preview';
|
||||||
|
|
||||||
const RFC822 = 'message/rfc822';
|
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 {
|
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
||||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
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);
|
if (cleaned) return cleaned.slice(0, 120);
|
||||||
return `attachment.${fallbackExt}`;
|
return `attachment.${fallbackExt}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user