fix(documents): RFC 5987 Content-Disposition so NFD filenames stop crashing inline view (#964)
* fix(documents): RFC 5987 Content-Disposition so NFD filenames stop crashing inline view macOS/iOS uploads carry NFD-decomposed filenames (base letter + combining diaeresis U+0308, char code 776). undici Headers require ByteString values (every code unit <= 0xFF), so splicing the raw filename into the Content-Disposition header threw while building the response and the inline document route 500ed. 122 prod documents across 35 companies hit this; last crash 2026-07-09T16:17. Add lib/api/content-disposition.ts emitting the RFC 6266 dual form: an ASCII quoted fallback (NFC-normalize, then replace anything outside printable ASCII plus quote and backslash with _) and filename*=UTF-8''<percent-encoded> per RFC 5987 (encodeURIComponent on the NFC name, additionally escaping ! ' ( ) * which it leaves bare). Use it in the inline document route and in the two latent same-shape sites that embed raw employee names in payslip PDF headers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): sanitize lone surrogates before percent-encoding Content-Disposition (CodeRabbit) Unpaired UTF-16 surrogates survive normalize('NFC') and make encodeURIComponent throw a URIError, so replace them with U+FFFD via String.prototype.toWellFormed() before encoding so the helper always returns a valid header value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5b505bb4a7
commit
b4a21b1029
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { contentDisposition } from '../content-disposition'
|
||||
|
||||
describe('contentDisposition', () => {
|
||||
it('passes a plain ASCII filename through unchanged in both forms', () => {
|
||||
expect(contentDisposition('inline', 'kvitto.pdf')).toBe(
|
||||
`inline; filename="kvitto.pdf"; filename*=UTF-8''kvitto.pdf`,
|
||||
)
|
||||
})
|
||||
|
||||
it('supports the attachment type', () => {
|
||||
expect(contentDisposition('attachment', 'report.pdf')).toBe(
|
||||
`attachment; filename="report.pdf"; filename*=UTF-8''report.pdf`,
|
||||
)
|
||||
})
|
||||
|
||||
it('produces a ByteString-safe value for an NFD Swedish filename', () => {
|
||||
// macOS/iOS NFD upload: base letter + combining diaeresis U+0308 (776),
|
||||
// the exact shape that crashed the inline route in prod.
|
||||
const nfd = 'kvitto fo\u0308rvaring.pdf'
|
||||
expect(nfd.charCodeAt(9)).toBe(776)
|
||||
|
||||
const header = contentDisposition('inline', nfd)
|
||||
|
||||
const maxCode = Math.max(...[...header].map((c) => c.charCodeAt(0)))
|
||||
expect(maxCode).toBeLessThanOrEqual(255)
|
||||
// The whole header is in fact printable ASCII.
|
||||
expect(/^[\x20-\x7e]*$/.test(header)).toBe(true)
|
||||
// filename* carries the NFC-composed UTF-8 percent-encoding of the name.
|
||||
expect(header).toContain(`filename*=UTF-8''kvitto%20f%C3%B6rvaring.pdf`)
|
||||
// The quoted fallback replaces the non-ASCII character with _.
|
||||
expect(header).toContain('filename="kvitto f_rvaring.pdf"')
|
||||
})
|
||||
|
||||
it('is stable across NFD and NFC inputs of the same name', () => {
|
||||
const nfd = 'lo\u0308n.pdf' // o + combining diaeresis
|
||||
const nfc = 'l\u00f6n.pdf' // precomposed \u00f6
|
||||
expect(contentDisposition('inline', nfd)).toBe(contentDisposition('inline', nfc))
|
||||
})
|
||||
|
||||
it('does not throw when used as an undici Headers value', () => {
|
||||
const nfd = 'lo\u0308nespec_a\u030agren.pdf'
|
||||
expect(
|
||||
() => new Headers({ 'Content-Disposition': contentDisposition('inline', nfd) }),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('neutralizes quote and CRLF header injection', () => {
|
||||
const header = contentDisposition('attachment', 'evil"\r\nSet-Cookie: x=y.pdf')
|
||||
expect(header).not.toContain('\r')
|
||||
expect(header).not.toContain('\n')
|
||||
expect(header).toContain('filename="evil___Set-Cookie: x=y.pdf"')
|
||||
// The extended form percent-encodes them instead of emitting them raw.
|
||||
expect(header).toContain('%0D%0A')
|
||||
})
|
||||
|
||||
it('replaces backslash in the quoted fallback', () => {
|
||||
expect(contentDisposition('inline', 'a\\b.pdf')).toContain('filename="a_b.pdf"')
|
||||
})
|
||||
|
||||
it(`percent-escapes ! ' ( ) * which encodeURIComponent leaves bare`, () => {
|
||||
const header = contentDisposition('inline', "a!'()*.pdf")
|
||||
expect(header).toContain(`filename*=UTF-8''a%21%27%28%29%2A.pdf`)
|
||||
})
|
||||
|
||||
it('sanitizes a lone high surrogate instead of throwing', () => {
|
||||
let header = ''
|
||||
expect(() => {
|
||||
header = contentDisposition('attachment', '\uD800')
|
||||
}).not.toThrow()
|
||||
// The lone surrogate becomes U+FFFD: _ in the fallback, percent-encoded
|
||||
// UTF-8 (%EF%BF%BD) in the extended form.
|
||||
expect(header).toBe(`attachment; filename="_"; filename*=UTF-8''%EF%BF%BD`)
|
||||
expect(() => new Headers({ 'Content-Disposition': header })).not.toThrow()
|
||||
})
|
||||
|
||||
it('sanitizes an embedded unpaired surrogate and keeps the rest of the name', () => {
|
||||
let header = ''
|
||||
expect(() => {
|
||||
header = contentDisposition('inline', 'a\uD800b')
|
||||
}).not.toThrow()
|
||||
expect(header).toBe(`inline; filename="a_b"; filename*=UTF-8''a%EF%BF%BDb`)
|
||||
expect(() => new Headers({ 'Content-Disposition': header })).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps a valid surrogate pair (emoji) working unchanged', () => {
|
||||
const header = contentDisposition('inline', 'r😀.pdf')
|
||||
expect(header).toBe(`inline; filename="r__.pdf"; filename*=UTF-8''r%F0%9F%98%80.pdf`)
|
||||
expect(() => new Headers({ 'Content-Disposition': header })).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* RFC 6266 Content-Disposition builder with RFC 5987 extended filename
|
||||
* encoding.
|
||||
*
|
||||
* undici (the fetch/Headers implementation in the Next.js runtime) requires
|
||||
* header values to be ByteStrings: every code unit <= 0xFF. Splicing a raw
|
||||
* filename into the header therefore throws for any non-Latin-1 character,
|
||||
* e.g. the NFD combining diaeresis (U+0308) that macOS/iOS uploads put in
|
||||
* Swedish filenames, turning the whole response into a 500.
|
||||
*
|
||||
* The dual form emitted here is:
|
||||
*
|
||||
* <type>; filename="<ascii fallback>"; filename*=UTF-8''<percent-encoded>
|
||||
*
|
||||
* Legacy clients read `filename`; modern browsers prefer `filename*`
|
||||
* (RFC 6266 section 4.3) and decode the original UTF-8 name.
|
||||
*/
|
||||
export function contentDisposition(
|
||||
type: 'inline' | 'attachment',
|
||||
filename: string,
|
||||
): string {
|
||||
// Lone/unpaired UTF-16 surrogates survive normalize('NFC') and make
|
||||
// encodeURIComponent below throw a URIError, which would turn the download
|
||||
// response into the very 500 this helper exists to prevent. Replace them
|
||||
// with U+FFFD first so the function always returns a valid header value.
|
||||
// Then normalize NFD (macOS/iOS) to NFC so precomposed characters encode
|
||||
// as themselves instead of base letter + combining mark.
|
||||
const normalized = filename.toWellFormed().normalize('NFC')
|
||||
|
||||
// ASCII fallback for the quoted-string form: anything outside printable
|
||||
// ASCII, plus the quoted-string specials " and \, becomes _. This also
|
||||
// neutralizes CR/LF header injection.
|
||||
const fallback = normalized.replace(/[^\x20-\x7e]|["\\]/g, '_')
|
||||
|
||||
// RFC 5987 value-chars: encodeURIComponent covers everything except
|
||||
// ! ' ( ) * which it leaves bare but RFC 5987 forbids unencoded.
|
||||
const encoded = encodeURIComponent(normalized).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
)
|
||||
|
||||
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`
|
||||
}
|
||||
Reference in New Issue
Block a user