From 2b4394d761dd7d9d4feeec62efd35dae9331e195 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 1 Aug 2026 20:48:05 +0700 Subject: [PATCH] fix(core): escape backslashes in Content-Disposition quoted filenames The quoted-string escaper in generateContentDisposition escaped " but not \, so a filename ending in a backslash produced an escaped closing quote, letting the remainder of the filename be smuggled into additional header parameters. Escape both characters, and teach getFilenameFromContentDisposition to unescape any quoted-pair so round-tripping stays consistent. --- packages/core/src/utils.test.ts | 9 +++++++++ packages/core/src/utils.ts | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index 5b1d09d..7f151cd 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -17,6 +17,13 @@ describe('generateContentDisposition', () => { expect(generateContentDisposition('!@#$%^%^&*()\'".txt')).toEqual('inline; filename="!@#$%^%^&*()\'\\".txt"; filename*=utf-8\'\'!%40%23%24%25^%25^%26%2A%28%29%27%22.txt') }) + it('escape \\ special char', () => { + expect(generateContentDisposition('a\\b.txt')).toEqual('inline; filename="a\\\\b.txt"; filename*=utf-8\'\'a%5Cb.txt') + // a trailing backslash must not escape the closing quote + expect(generateContentDisposition('a\\')).toEqual('inline; filename="a\\\\"; filename*=utf-8\'\'a%5C') + expect(generateContentDisposition('a\\"; injected=x')).toEqual('inline; filename="a\\\\\\"; injected=x"; filename*=utf-8\'\'a%5C%22%3B%20injected%3Dx') + }) + it('escape non-ASCII filenames', () => { expect(generateContentDisposition('テンプレ\'"ート.txt')).toEqual('inline; filename="____\'\\"__.txt"; filename*=utf-8\'\'%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%27%22%E3%83%BC%E3%83%88.txt') }) @@ -30,6 +37,8 @@ it('getFilenameFromContentDisposition', () => { expect(getFilenameFromContentDisposition('attachment; filename=""')).toEqual('') expect(getFilenameFromContentDisposition('attachment; filename="test.txt"')).toEqual('test.txt') expect(getFilenameFromContentDisposition('attachment; filename="!@#$%^%^&*()\'\\".txt"')).toEqual('!@#$%^%^&*()\'".txt') + expect(getFilenameFromContentDisposition('attachment; filename="a\\\\b.txt"')).toEqual('a\\b.txt') + expect(getFilenameFromContentDisposition('attachment; filename="a\\\\"')).toEqual('a\\') expect(getFilenameFromContentDisposition('attachment; filename*=utf-8\'\'')).toEqual('') expect(getFilenameFromContentDisposition('attachment; filename*=utf-8\'\'test.txt')).toEqual('test.txt') diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 71f4043..6b03a39 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,7 +2,7 @@ import type { StandardHeaders, StandardUrl } from './types' import { toArray, tryDecodeURIComponent } from '@standardserver/shared' export function generateContentDisposition(filename: string): string { - const encodedFilename = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '\\"') + const encodedFilename = filename.replace(/[^\x20-\x7E]/g, '_').replace(/[\\"]/g, '\\$&') // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers const encodedFilenameStar = encodeURIComponent(filename) @@ -19,9 +19,9 @@ export function getFilenameFromContentDisposition(contentDisposition: string): s return tryDecodeURIComponent(encodedFilenameStarMatch[2]) } - const encodedFilenameMatch = contentDisposition.match(/filename="((?:\\"|[^"])*)"/i) + const encodedFilenameMatch = contentDisposition.match(/filename="((?:\\.|[^"\\])*)"/i) if (encodedFilenameMatch && typeof encodedFilenameMatch[1] === 'string') { - return encodedFilenameMatch[1].replace(/\\"/g, '"') + return encodedFilenameMatch[1].replace(/\\(.)/g, '$1') } }