diff --git a/packages/core/README.md b/packages/core/README.md index e17aa8d6..6ca112d2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -294,7 +294,7 @@ const filename = getFilenameFromContentDisposition(disposition) // 'report "Q2".csv' ``` -`generateContentDisposition()` preserves an ASCII-safe `filename="..."` value and also emits `filename*=` for UTF-8 aware clients. +`generateContentDisposition()` emits the exact name in `filename*=` for UTF-8 aware clients, plus an ASCII-safe `filename="..."` fallback where non-ASCII characters, `;` and `=` become `_`, so quote-unaware parsers cannot misread it. ### Header helpers diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index 2b5f18f2..8780deda 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -22,7 +22,23 @@ describe('generateContentDisposition', () => { 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') + expect(generateContentDisposition('a\\"; injected=x')).toEqual('inline; filename="a\\\\\\"_ injected_x"; filename*=utf-8\'\'a%5C%22%3B%20injected%3Dx') + }) + + it('round-trip through getFilenameFromContentDisposition', () => { + const filenames = [ + '', + 'test.txt', + 'invoice;filename*=utf-8\'\'invoice.exe;.pdf', + 'a;filename="evil.exe".txt', + 'a\\"; filename=evil.exe', + '!@#$%^%^&*()\'"=;.txt', + 'テンプレ\'";ート.txt', + ] + + for (const filename of filenames) { + expect(getFilenameFromContentDisposition(generateContentDisposition(filename))).toEqual(filename) + } }) it('escape non-ASCII filenames', () => { @@ -79,6 +95,25 @@ it('getFilenameFromContentDisposition', () => { expect(getFilenameFromContentDisposition('attachment; filename*=us-ascii\'en\'test.txt')).toEqual('test.txt') expect(getFilenameFromContentDisposition('attachment; filename*=iso-8859-1\'\'%E9.txt; filename="fallback.txt"')).toEqual('fallback.txt') expect(getFilenameFromContentDisposition('attachment; filename*=iso-8859-1\'\'%E9.txt')).toEqual(undefined) + + // ';' and parameters inside a quoted-string are part of the value + expect(getFilenameFromContentDisposition('inline; filename="invoice;filename*=utf-8\'\'invoice.exe;.pdf"; filename*=utf-8\'\'invoice%3Bfilename%2A%3Dutf-8%27%27invoice.exe%3B.pdf')).toEqual('invoice;filename*=utf-8\'\'invoice.exe;.pdf') + expect(getFilenameFromContentDisposition('inline; filename="report;filename*=utf-8\'\'evil.exe"')).toEqual('report;filename*=utf-8\'\'evil.exe') + expect(getFilenameFromContentDisposition('inline; filename="a;filename=evil.exe"')).toEqual('a;filename=evil.exe') + expect(getFilenameFromContentDisposition('inline; x="; filename=evil.exe"; filename="good.txt"')).toEqual('good.txt') + expect(getFilenameFromContentDisposition('inline; x="\\"; filename=evil.exe"; filename="good.txt"')).toEqual('good.txt') + + // an unterminated quoted-string runs to the end of the header + expect(getFilenameFromContentDisposition('inline; filename="a.txt; filename*=utf-8\'\'evil.exe')).toEqual(undefined) + expect(getFilenameFromContentDisposition('inline; x="; filename*=utf-8\'\'evil.exe')).toEqual(undefined) + expect(getFilenameFromContentDisposition('inline; filename="a\\"')).toEqual(undefined) + + // quoted filename* is tolerated + expect(getFilenameFromContentDisposition('attachment; filename*="utf-8\'\'%E2%82%AC.txt"')).toEqual('€.txt') + + // whitespace around '=' and parameter names are case-insensitive + expect(getFilenameFromContentDisposition('attachment; FileName = "test.txt"')).toEqual('test.txt') + expect(getFilenameFromContentDisposition('attachment; FILENAME*= UTF-8\'\'test.txt')).toEqual('test.txt') }) describe('resolveStandardBodyHint', () => { diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 927198e8..e3a17547 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,43 +1,64 @@ import type { StandardBody, StandardBodyHint, StandardHeaders, StandardUrl } from './types' import { isAsyncIteratorObject, safeDecodeURIComponent, safeEncodeURIComponent, toArray } from '@standard-server/shared' +const FALLBACK_FILENAME_UNSAFE_CHAR_REGEX = /[^\x20-\x7E]|[;=]/g +const QUOTED_STRING_SPECIAL_CHAR_REGEX = /[\\"]/g +const QUOTED_PAIR_REGEX = /\\(.)/g + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers +const EXT_VALUE_RESERVED_CHAR_REGEX = /['()*]/g +const EXT_VALUE_ALLOWED_ESCAPE_REGEX = /%(7C|60|5E)/g + +// RFC 8187 ext-value: charset "'" [ language ] "'" value-chars +const EXT_VALUE_REGEX = /^([^']*)'[^']*'(.*)$/ +const EXT_VALUE_SUPPORTED_CHARSET_REGEX = /^(?:utf-8|us-ascii)$/i + +const CONTENT_DISPOSITION_PARAM_REGEX = /[\s;]*([^;=]*)(?:=\s*(?:"((?:\\.|[^"\\])*)"[^;]*|"[\s\S]*|([^;]*)))?/y + export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { - const encodedFilename = filename.replace(/[^\x20-\x7E]/g, '_').replace(/[\\"]/g, '\\$&') + const encodedFilename = filename + .replace(FALLBACK_FILENAME_UNSAFE_CHAR_REGEX, '_') + .replace(QUOTED_STRING_SPECIAL_CHAR_REGEX, '\\$&') - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers const encodedFilenameStar = safeEncodeURIComponent(filename) - .replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) - .replace(/%(7C|60|5E)/g, (str, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(EXT_VALUE_RESERVED_CHAR_REGEX, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(EXT_VALUE_ALLOWED_ESCAPE_REGEX, (str, hex) => String.fromCharCode(Number.parseInt(hex, 16))) return `${type}; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}` } -export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined { - const extValue = contentDisposition.match(/(?:^|;)\s*filename\*=([^;]*)/i)?.[1]?.trim() +function getContentDispositionParam(contentDisposition: string, name: string): string | undefined { + CONTENT_DISPOSITION_PARAM_REGEX.lastIndex = 0 + + while (CONTENT_DISPOSITION_PARAM_REGEX.lastIndex < contentDisposition.length) { + const [, paramName = '', quoted, token] = CONTENT_DISPOSITION_PARAM_REGEX.exec(contentDisposition)! - // RFC 8187 ext-value: charset "'" [ language ] "'" value-chars - const extValueMatch = extValue?.match(/^([^']*)'[^']*'(.*)$/) + if (paramName.trimEnd().toLowerCase() === name) { + return quoted !== undefined + ? quoted.replace(QUOTED_PAIR_REGEX, '$1') + : token?.trim() || undefined + } + } + + return undefined +} + +export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined { + const extValue = getContentDispositionParam(contentDisposition, 'filename*') + const extValueMatch = extValue?.match(EXT_VALUE_REGEX) if (extValueMatch) { const [, charset = '', encodedFilename = ''] = extValueMatch - if (/^(?:utf-8|us-ascii)$/i.test(charset)) { + if (EXT_VALUE_SUPPORTED_CHARSET_REGEX.test(charset)) { return safeDecodeURIComponent(encodedFilename) } - // unsupported charset: fall through to the plain filename param } else if (extValue) { - // lenient: some senders omit the charset prefix entirely return safeDecodeURIComponent(extValue) } - const filenameMatch = contentDisposition.match(/(?:^|;)\s*filename=(?:"((?:\\.|[^"\\])*)"|([^";]*))/i) - - if (filenameMatch?.[1] !== undefined) { - return filenameMatch[1].replace(/\\(.)/g, '$1') - } - - return filenameMatch?.[2]?.trim() || undefined + return getContentDispositionParam(contentDisposition, 'filename') } export function flattenStandardHeader(header: string | readonly string[] | undefined): string | undefined {