Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 36 additions & 1 deletion packages/core/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
57 changes: 39 additions & 18 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Comment thread
dinwwwh marked this conversation as resolved.
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 {
Expand Down
Loading