Files
accounted/lib/api/content-disposition.ts
T
Jakob Wennberg b4a21b1029 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>
2026-07-10 11:03:50 +02:00

44 lines
1.9 KiB
TypeScript

/**
* 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}`
}