fix: malformed Content-Disposition headers for downloads

Massimo Melina committed Jul 23, 2026 at 22:16 UTC 107212f209ea9047a2a76c03c1a24420c549bb21
2 files changed +13 -3
src/serveFile.ts
+5 -3
@@ -34,9 +34,11 @@ export function forceDownload(ctx: Koa.Context, name: string) {
34 }
35
36 export function disposition(ctx: Koa.Context, name: string, forceDownload=false) {
37 - // ctx.attachment is not working well on Windows. Eg: for the file "èÖ.txt" it is producing `Content-Disposition: attachment; filename="??.txt"`. Koa uses module content-disposition, that actually produces a better result anyway: ``
38 - ctx.set('Content-Disposition', (forceDownload ? 'attachment; ' : '')
39 - + `filename="${toAsciiEquivalent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`)
37 + // override Koa's question-mark fallback for decomposed Unicode filenames on Windows
38 + ctx.attachment(name, {
39 + type: forceDownload ? 'attachment' : 'inline',
40 + fallback: toAsciiEquivalent(name),
41 + })
42 }
43
44 export async function serveFileNode(ctx: Koa.Context, node: VfsNode) {
tests/test.ts
+8
@@ -118,6 +118,14 @@ describe('basics', () => {
118 }
119 })
120 test('download.mime', req('/f1/f2/alfa.txt', { re:/abcd/, mime:'text/plain' }))
121 + test('download.disposition', req('/f1/f2/alfa.txt', (_data, res) => res.headers['content-disposition'].startsWith('inline; filename=')))
122 + test('download.disposition quotes', { skip: process.platform === 'win32' }, async () => {
123 + const name = '"quoted".txt'
124 + const file = resolve(__dirname, name)
125 + await writeFile(file, '')
126 + await req('/tests/' + pathEncode(name), (_data, res) => res.headers['content-disposition'].includes('filename="\\"quoted\\".txt"'))()
127 + .finally(() => rm(file))
128 + })
129 test('download.not modified', async () => {
130 let lm = ''
131 await req('/f1/f2/alfa.txt', (_data, res) => lm = res.headers?.['last-modified'])()