diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index 7f151cd..5451de6 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -27,6 +27,11 @@ describe('generateContentDisposition', () => { 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') }) + + it('support inline and attachment types', () => { + expect(generateContentDisposition('test.txt', 'inline')).toEqual('inline; filename="test.txt"; filename*=utf-8\'\'test.txt') + expect(generateContentDisposition('test.txt', 'attachment')).toEqual('attachment; filename="test.txt"; filename*=utf-8\'\'test.txt') + }) }) it('getFilenameFromContentDisposition', () => { @@ -49,6 +54,25 @@ it('getFilenameFromContentDisposition', () => { expect(getFilenameFromContentDisposition('inline; filename"hello.txt"; size=123')).toEqual(undefined) expect(getFilenameFromContentDisposition('inline; filename*=!%40%23%24%25^%25^%26%2A%28%29%27%22.txt; size=123')).toEqual('!@#$%^%^&*()\'".txt') + + // unquoted token form + expect(getFilenameFromContentDisposition('attachment; filename=report.pdf')).toEqual('report.pdf') + expect(getFilenameFromContentDisposition('attachment; filename=report.pdf; size=123')).toEqual('report.pdf') + expect(getFilenameFromContentDisposition('attachment; filename=')).toEqual(undefined) + + // param names must be anchored, not substring-matched + expect(getFilenameFromContentDisposition('attachment; xfilename*=evil')).toEqual(undefined) + expect(getFilenameFromContentDisposition('attachment; filename="good.txt"; xfilename*=evil.exe')).toEqual('good.txt') + expect(getFilenameFromContentDisposition('attachment; creation-filename="e.exe"')).toEqual(undefined) + expect(getFilenameFromContentDisposition('filename*=utf-8\'\'first.txt')).toEqual('first.txt') + expect(getFilenameFromContentDisposition('filename=first.txt')).toEqual('first.txt') + + // ext-value charset and language prefix + expect(getFilenameFromContentDisposition('attachment; filename*=utf-8\'en\'%E2%82%AC.txt')).toEqual('€.txt') + expect(getFilenameFromContentDisposition('attachment; filename*=UTF-8\'\'%E2%82%AC.txt')).toEqual('€.txt') + 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) }) describe('mergeStandardHeaders', () => { diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 6b03a39..daea321 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,7 +1,7 @@ import type { StandardHeaders, StandardUrl } from './types' import { toArray, tryDecodeURIComponent } from '@standardserver/shared' -export function generateContentDisposition(filename: string): string { +export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string { 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 @@ -9,20 +9,35 @@ export function generateContentDisposition(filename: string): string { .replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) .replace(/%(7C|60|5E)/g, (str, hex) => String.fromCharCode(Number.parseInt(hex, 16))) - return `inline; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}` + return `${type}; filename="${encodedFilename}"; filename*=utf-8''${encodedFilenameStar}` } export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined { - const encodedFilenameStarMatch = contentDisposition.match(/filename\*=(UTF-8'')?([^;]*)/i) + const extValue = contentDisposition.match(/(?:^|;)\s*filename\*=([^;]*)/i)?.[1]?.trim() - if (encodedFilenameStarMatch && typeof encodedFilenameStarMatch[2] === 'string') { - return tryDecodeURIComponent(encodedFilenameStarMatch[2]) + // RFC 8187 ext-value: charset "'" [ language ] "'" value-chars + const extValueMatch = extValue?.match(/^([^']*)'[^']*'(.*)$/) + + if (extValueMatch) { + const [, charset = '', encodedFilename = ''] = extValueMatch + + if (/^(?:utf-8|us-ascii)$/i.test(charset)) { + return tryDecodeURIComponent(encodedFilename) + } + // unsupported charset: fall through to the plain filename param } + else if (extValue) { + // lenient: some senders omit the charset prefix entirely + return tryDecodeURIComponent(extValue) + } + + const filenameMatch = contentDisposition.match(/(?:^|;)\s*filename=(?:"((?:\\.|[^"\\])*)"|([^";]*))/i) - const encodedFilenameMatch = contentDisposition.match(/filename="((?:\\.|[^"\\])*)"/i) - if (encodedFilenameMatch && typeof encodedFilenameMatch[1] === 'string') { - return encodedFilenameMatch[1].replace(/\\(.)/g, '$1') + if (filenameMatch?.[1] !== undefined) { + return filenameMatch[1].replace(/\\(.)/g, '$1') } + + return filenameMatch?.[2]?.trim() || undefined } export function flattenStandardHeader(header: string | readonly string[] | undefined): string | undefined {