diff --git a/ts/jobs/AttachmentLocalBackupManager.preload.ts b/ts/jobs/AttachmentLocalBackupManager.preload.ts index 3dfa1f9059..194680e72a 100644 --- a/ts/jobs/AttachmentLocalBackupManager.preload.ts +++ b/ts/jobs/AttachmentLocalBackupManager.preload.ts @@ -115,14 +115,15 @@ export async function runAttachmentBackupJob( } } -function getExtension( +/** @testexport */ +export function getExtension( contentType: string | undefined, fileName: string | undefined ): string | undefined { if (fileName) { const extension = extname(fileName).replace(/^./, ''); - if (extension) { + if (isValidExtension(extension)) { return extension; } } @@ -132,31 +133,41 @@ function getExtension( } if (contentType.startsWith('application/x-')) { - return contentType.replace('application/x-', ''); + return normalizeExtension(contentType.replace('application/x-', '')); } if (contentType.startsWith('application/')) { - return contentType.replace('application/', ''); + return normalizeExtension(contentType.replace('application/', '')); } if (contentType.startsWith('audio/')) { - return contentType.replace('audio/', ''); + return normalizeExtension(contentType.replace('audio/', '')); } if (contentType.startsWith('image/')) { - return contentType.replace('image/', ''); + return normalizeExtension(contentType.replace('image/', '')); } if (contentType === 'text/x-signal-plain') { return 'txt'; } if (contentType.startsWith('text/x-')) { - return contentType.replace('text/x-', ''); + return normalizeExtension(contentType.replace('text/x-', '')); } if (contentType.startsWith('video/')) { - return contentType.replace('video/', ''); + return normalizeExtension(contentType.replace('video/', '')); } return undefined; } + +const VALID_EXTENSION_REGEXP = /^[A-Za-z\d](?:[\w+.-]{0,30}[A-Za-z\d])?$/; + +function isValidExtension(extension: string): boolean { + return VALID_EXTENSION_REGEXP.test(extension); +} + +function normalizeExtension(extension: string): string | undefined { + return isValidExtension(extension) ? extension : undefined; +} diff --git a/ts/test-electron/services/AttachmentLocalBackupManager_test.preload.ts b/ts/test-electron/services/AttachmentLocalBackupManager_test.preload.ts new file mode 100644 index 0000000000..41c531ae12 --- /dev/null +++ b/ts/test-electron/services/AttachmentLocalBackupManager_test.preload.ts @@ -0,0 +1,43 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { getExtension } from '../../jobs/AttachmentLocalBackupManager.preload.ts'; +import { IMAGE_JPEG } from '../../types/MIME.std.ts'; + +describe('AttachmentLocalBackupManager', () => { + describe('getExtension', () => { + it('prefers valid filename extensions', () => { + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment.png'), 'png'); + assert.strictEqual(getExtension(undefined, 'archive.tar.gz'), 'gz'); + assert.strictEqual(getExtension(undefined, 'source.c'), 'c'); + assert.strictEqual(getExtension(undefined, 'image.svg+xml'), 'svg+xml'); + }); + + it('falls back to content type when the filename extension is invalid', () => { + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment.*'), 'jpeg'); + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment._png'), 'jpeg'); + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment.png_'), 'jpeg'); + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment.+png'), 'jpeg'); + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment.png+'), 'jpeg'); + assert.strictEqual(getExtension(IMAGE_JPEG, 'attachment.png-'), 'jpeg'); + assert.strictEqual( + getExtension('application/vnd.ms-excel', 'attachment.'), + 'vnd.ms-excel' + ); + }); + + it('ignores invalid content type extensions', () => { + assert.strictEqual( + getExtension('application/*', 'attachment.*'), + undefined + ); + assert.strictEqual(getExtension('image/jpeg.', undefined), undefined); + }); + + it('uses txt for signal plain text attachments', () => { + assert.strictEqual(getExtension('text/x-signal-plain', undefined), 'txt'); + }); + }); +});