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'])()