Guard against invalid extensions during chat export

This commit is contained in:
trevor-signal
2026-05-29 16:30:36 -04:00
committed by GitHub
parent 89550b31fe
commit 99f704363d
2 changed files with 62 additions and 8 deletions
@@ -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;
}
@@ -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');
});
});
});