From ba49751659d765b429ffef2dc8f3e4614a1ca061 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:59:26 +0000 Subject: [PATCH 1/5] fix(core): honor quoted-strings when parsing Content-Disposition filename getFilenameFromContentDisposition matched `filename*=` / `filename=` with quote-unaware regexes, so a `;filename*=...` inside the quoted legacy filename was taken as the real parameter. A File named "invoice;filename*=utf-8''invoice.exe;.pdf" arrived as "invoice.exe" on fetch, node, peer and aws-lambda receivers. Parse parameters with a quote-aware tokenizer (first occurrence wins, unterminated quoted-strings run to the end), and drop ';' and '=' from the generated fallback filename so receivers still on the old parser cannot be misled either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0119s8HvkwUjgBK9EGP6gRWs --- packages/core/src/utils.test.ts | 43 ++++++++++++++++++++++++++++++++- packages/core/src/utils.ts | 36 ++++++++++++++++++++------- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index 2b5f18f2..fd38786e 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -22,7 +22,28 @@ 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('keep parameter syntax out of the fallback filename', () => { + expect(generateContentDisposition('invoice;filename*=utf-8\'\'invoice.exe;.pdf')).toEqual('inline; filename="invoice_filename*_utf-8\'\'invoice.exe_.pdf"; filename*=utf-8\'\'invoice%3Bfilename%2A%3Dutf-8%27%27invoice.exe%3B.pdf') + }) + + it('round-trip through getFilenameFromContentDisposition', () => { + const filenames = [ + '', + 'test.txt', + 'invoice;filename*=utf-8\'\'invoice.exe;.pdf', + 'report;filename*=utf-8\'\'evil.exe', + '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 +100,26 @@ 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"; filename*=utf-8\'\'report%3Bfilename%2A%3Dutf-8%27%27evil.exe')).toEqual('report;filename*=utf-8\'\'evil.exe') + 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..d669788d 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,7 +2,9 @@ import type { StandardBody, StandardBodyHint, StandardHeaders, StandardUrl } fro import { isAsyncIteratorObject, safeDecodeURIComponent, safeEncodeURIComponent, toArray } from '@standard-server/shared' export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { - const encodedFilename = filename.replace(/[^\x20-\x7E]/g, '_').replace(/[\\"]/g, '\\$&') + // ';' and '=' are legal in a quoted-string, but a quote-unaware parser would read them + // as parameters (e.g. a filename containing `;filename*=utf-8''evil.exe`), so keep them out of the fallback + 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 = safeEncodeURIComponent(filename) @@ -12,8 +14,30 @@ export function generateContentDisposition(filename: string, type: 'inline' | 'a return `${type}; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}` } +/** + * name, then a quoted-string (group 2, anything after it before the next ';' is malformed and skipped), + * an unterminated quoted-string that runs to the end, or a token (group 3) + */ +const CONTENT_DISPOSITION_PARAM_REGEX = /([^;=]*)(?:=\s*(?:"((?:\\.|[^"\\])*)"[^;]*|"[\s\S]*|([^;]*)))?;?/g + +/** + * Returns the first value of a Content-Disposition parameter. + * Quoted-strings are honored, so a ';' or `filename*=` inside a quoted value is never read as a parameter. + */ +function getContentDispositionParam(contentDisposition: string, name: string): string | undefined { + for (const [, paramName = '', quoted, token] of contentDisposition.matchAll(CONTENT_DISPOSITION_PARAM_REGEX)) { + if (paramName.trim().toLowerCase() === name) { + return quoted !== undefined + ? quoted.replace(/\\(.)/g, '$1') + : token?.trim() || undefined + } + } + + return undefined +} + export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined { - const extValue = contentDisposition.match(/(?:^|;)\s*filename\*=([^;]*)/i)?.[1]?.trim() + const extValue = getContentDispositionParam(contentDisposition, 'filename*') // RFC 8187 ext-value: charset "'" [ language ] "'" value-chars const extValueMatch = extValue?.match(/^([^']*)'[^']*'(.*)$/) @@ -31,13 +55,7 @@ export function getFilenameFromContentDisposition(contentDisposition: string): s 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 { From cc4d633d2a2c067e521aaa20c6d54faeceb885b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 02:38:04 +0000 Subject: [PATCH 2/5] perf(core): parse Content-Disposition params with a sticky exec loop Replace matchAll with a sticky regex exec loop and let a leading [\s;]* absorb separator runs in one match. Typical headers parse in about half the time, and ';'-flooded headers go from ~2ms to ~16us. Also trim the comments and drop test cases that duplicated existing ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0119s8HvkwUjgBK9EGP6gRWs --- packages/core/src/utils.test.ts | 6 ------ packages/core/src/utils.ts | 23 ++++++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index fd38786e..8780deda 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -25,16 +25,11 @@ describe('generateContentDisposition', () => { expect(generateContentDisposition('a\\"; injected=x')).toEqual('inline; filename="a\\\\\\"_ injected_x"; filename*=utf-8\'\'a%5C%22%3B%20injected%3Dx') }) - it('keep parameter syntax out of the fallback filename', () => { - expect(generateContentDisposition('invoice;filename*=utf-8\'\'invoice.exe;.pdf')).toEqual('inline; filename="invoice_filename*_utf-8\'\'invoice.exe_.pdf"; filename*=utf-8\'\'invoice%3Bfilename%2A%3Dutf-8%27%27invoice.exe%3B.pdf') - }) - it('round-trip through getFilenameFromContentDisposition', () => { const filenames = [ '', 'test.txt', 'invoice;filename*=utf-8\'\'invoice.exe;.pdf', - 'report;filename*=utf-8\'\'evil.exe', 'a;filename="evil.exe".txt', 'a\\"; filename=evil.exe', '!@#$%^%^&*()\'"=;.txt', @@ -103,7 +98,6 @@ it('getFilenameFromContentDisposition', () => { // ';' 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"; filename*=utf-8\'\'report%3Bfilename%2A%3Dutf-8%27%27evil.exe')).toEqual('report;filename*=utf-8\'\'evil.exe') 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') diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index d669788d..148b2ecb 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,8 +2,7 @@ import type { StandardBody, StandardBodyHint, StandardHeaders, StandardUrl } fro import { isAsyncIteratorObject, safeDecodeURIComponent, safeEncodeURIComponent, toArray } from '@standard-server/shared' export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { - // ';' and '=' are legal in a quoted-string, but a quote-unaware parser would read them - // as parameters (e.g. a filename containing `;filename*=utf-8''evil.exe`), so keep them out of the fallback + // ';' and '=' are legal when quoted, but a quote-unaware parser would read them as new parameters 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 @@ -15,18 +14,20 @@ export function generateContentDisposition(filename: string, type: 'inline' | 'a } /** - * name, then a quoted-string (group 2, anything after it before the next ';' is malformed and skipped), - * an unterminated quoted-string that runs to the end, or a token (group 3) + * name (group 1), then a quoted-string (group 2, anything after it before the next ';' is malformed and skipped), + * an unterminated quoted-string that runs to the end, or a token (group 3). + * Always matches, and advances while input remains. */ -const CONTENT_DISPOSITION_PARAM_REGEX = /([^;=]*)(?:=\s*(?:"((?:\\.|[^"\\])*)"[^;]*|"[\s\S]*|([^;]*)))?;?/g +const CONTENT_DISPOSITION_PARAM_REGEX = /[\s;]*([^;=]*)(?:=\s*(?:"((?:\\.|[^"\\])*)"[^;]*|"[\s\S]*|([^;]*)))?/y -/** - * Returns the first value of a Content-Disposition parameter. - * Quoted-strings are honored, so a ';' or `filename*=` inside a quoted value is never read as a parameter. - */ +/** First value of a Content-Disposition parameter, honoring quoted-strings. */ function getContentDispositionParam(contentDisposition: string, name: string): string | undefined { - for (const [, paramName = '', quoted, token] of contentDisposition.matchAll(CONTENT_DISPOSITION_PARAM_REGEX)) { - if (paramName.trim().toLowerCase() === name) { + CONTENT_DISPOSITION_PARAM_REGEX.lastIndex = 0 + + while (CONTENT_DISPOSITION_PARAM_REGEX.lastIndex < contentDisposition.length) { + const [, paramName = '', quoted, token] = CONTENT_DISPOSITION_PARAM_REGEX.exec(contentDisposition)! + + if (paramName.trimEnd().toLowerCase() === name) { return quoted !== undefined ? quoted.replace(/\\(.)/g, '$1') : token?.trim() || undefined From f283134ae32a7f99ae30e1bf9c1669152ebb4fac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 02:44:35 +0000 Subject: [PATCH 3/5] refactor(core): hoist Content-Disposition regexes to module scope Move the remaining inline regex literals in core utils into named module-level constants, matching the rest of the repo. The global ones are only used with replace(), which resets lastIndex, and the others are stateless, so sharing them is safe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0119s8HvkwUjgBK9EGP6gRWs --- packages/core/src/utils.ts | 39 ++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 148b2ecb..365c5e51 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,17 +1,18 @@ import type { StandardBody, StandardBodyHint, StandardHeaders, StandardUrl } from './types' import { isAsyncIteratorObject, safeDecodeURIComponent, safeEncodeURIComponent, toArray } from '@standard-server/shared' -export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { - // ';' and '=' are legal when quoted, but a quote-unaware parser would read them as new parameters - const encodedFilename = filename.replace(/[^\x20-\x7E]|[;=]/g, '_').replace(/[\\"]/g, '\\$&') +// ';' and '=' are legal when quoted, but a quote-unaware parser would read them as new parameters +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 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))) +// 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 - return `${type}; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}` -} +// RFC 8187 ext-value: charset "'" [ language ] "'" value-chars +const EXT_VALUE_REGEX = /^([^']*)'[^']*'(.*)$/ +const EXT_VALUE_SUPPORTED_CHARSET_REGEX = /^(?:utf-8|us-ascii)$/i /** * name (group 1), then a quoted-string (group 2, anything after it before the next ';' is malformed and skipped), @@ -20,6 +21,18 @@ export function generateContentDisposition(filename: string, type: 'inline' | 'a */ const CONTENT_DISPOSITION_PARAM_REGEX = /[\s;]*([^;=]*)(?:=\s*(?:"((?:\\.|[^"\\])*)"[^;]*|"[\s\S]*|([^;]*)))?/y +export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { + const encodedFilename = filename + .replace(FALLBACK_FILENAME_UNSAFE_CHAR_REGEX, '_') + .replace(QUOTED_STRING_SPECIAL_CHAR_REGEX, '\\$&') + + const encodedFilenameStar = safeEncodeURIComponent(filename) + .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}` +} + /** First value of a Content-Disposition parameter, honoring quoted-strings. */ function getContentDispositionParam(contentDisposition: string, name: string): string | undefined { CONTENT_DISPOSITION_PARAM_REGEX.lastIndex = 0 @@ -29,7 +42,7 @@ function getContentDispositionParam(contentDisposition: string, name: string): s if (paramName.trimEnd().toLowerCase() === name) { return quoted !== undefined - ? quoted.replace(/\\(.)/g, '$1') + ? quoted.replace(QUOTED_PAIR_REGEX, '$1') : token?.trim() || undefined } } @@ -39,14 +52,12 @@ function getContentDispositionParam(contentDisposition: string, name: string): s export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined { const extValue = getContentDispositionParam(contentDisposition, 'filename*') - - // RFC 8187 ext-value: charset "'" [ language ] "'" value-chars - const extValueMatch = extValue?.match(/^([^']*)'[^']*'(.*)$/) + 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 From 7f91351588f4e8e02092cce5c3f373d2164c0ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 02:46:19 +0000 Subject: [PATCH 4/5] refactor(core): drop Content-Disposition comments covered by tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0119s8HvkwUjgBK9EGP6gRWs --- packages/core/src/utils.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 365c5e51..e3a17547 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,7 +1,6 @@ import type { StandardBody, StandardBodyHint, StandardHeaders, StandardUrl } from './types' import { isAsyncIteratorObject, safeDecodeURIComponent, safeEncodeURIComponent, toArray } from '@standard-server/shared' -// ';' and '=' are legal when quoted, but a quote-unaware parser would read them as new parameters const FALLBACK_FILENAME_UNSAFE_CHAR_REGEX = /[^\x20-\x7E]|[;=]/g const QUOTED_STRING_SPECIAL_CHAR_REGEX = /[\\"]/g const QUOTED_PAIR_REGEX = /\\(.)/g @@ -14,11 +13,6 @@ const EXT_VALUE_ALLOWED_ESCAPE_REGEX = /%(7C|60|5E)/g const EXT_VALUE_REGEX = /^([^']*)'[^']*'(.*)$/ const EXT_VALUE_SUPPORTED_CHARSET_REGEX = /^(?:utf-8|us-ascii)$/i -/** - * name (group 1), then a quoted-string (group 2, anything after it before the next ';' is malformed and skipped), - * an unterminated quoted-string that runs to the end, or a token (group 3). - * Always matches, and advances while input remains. - */ const CONTENT_DISPOSITION_PARAM_REGEX = /[\s;]*([^;=]*)(?:=\s*(?:"((?:\\.|[^"\\])*)"[^;]*|"[\s\S]*|([^;]*)))?/y export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { @@ -33,7 +27,6 @@ export function generateContentDisposition(filename: string, type: 'inline' | 'a return `${type}; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}` } -/** First value of a Content-Disposition parameter, honoring quoted-strings. */ function getContentDispositionParam(contentDisposition: string, name: string): string | undefined { CONTENT_DISPOSITION_PARAM_REGEX.lastIndex = 0 @@ -60,10 +53,8 @@ export function getFilenameFromContentDisposition(contentDisposition: string): s 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) } From a0d10a61e050b54d08dbdfc8a8eb79200f5cd105 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 03:24:45 +0000 Subject: [PATCH 5/5] docs(core): document the Content-Disposition fallback filename replacements Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0119s8HvkwUjgBK9EGP6gRWs --- packages/core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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