fix: wrong error code in case of bad range in an http request
Massimo Melina committed
Feb 13, 2026 at 00:50 UTC
2dd29f662d63322d09e35d925b0ecc4043cb1e1c
2 files changed
+34
-21
src/serveFile.ts
+30
-15
@@ -7,7 +7,7 @@ import { HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONT
7
import { getNodeName, VfsNode } from './vfs'
8
import mimetypes from 'mime-types'
9
import { defineConfig } from './config'
10
-import { CFG, Dict, makeMatcher, matches, try_, with_ } from './misc'
10
+import { CFG, Dict, makeMatcher, matches, throw_, try_, with_, xlate } from './misc'
11
import _ from 'lodash'
12
import { basename } from 'path'
13
import { promisify } from 'util'
@@ -103,12 +103,21 @@ export async function serveFile(ctx: Koa.Context, source:string, mime?:string, c
103
ctx.set('Cache-Control', `max-age=${cc}`)
104
const { size } = stats
105
const range = applyRange(ctx, size)
106
- ctx.body = createReadStream(source, range)
106
+ if (ctx.status >= 400) return // applyRange may have set an error
107
+ ctx.body = createReadStream(source, range || undefined)
108
if (ctx.state.vfsNode)
109
monitorAsDownload(ctx, size, range?.start)
110
}
111
catch (e: any) {
111
- return ctx.status = HTTP_NOT_FOUND
112
+ const status = {
113
+ ENOENT: HTTP_NOT_FOUND,
114
+ ENOTDIR: HTTP_NOT_FOUND,
115
+ EACCES: HTTP_FORBIDDEN,
116
+ EPERM: HTTP_FORBIDDEN,
117
+ }[String(e?.code)]
118
+ if (!status)
119
+ throw e
120
+ ctx.status = status
121
}
122
}
123
@@ -137,7 +146,7 @@ declare module "koa" {
146
}
147
}
148
140
-export function applyRange(ctx: Koa.Context, totalSize=ctx.response.length) {
149
+export function applyRange(ctx: Koa.Context, totalSize=ctx.response.length): { start: number, end: number } | void {
150
ctx.set('Accept-Ranges', 'bytes')
151
const { range } = ctx.request.header
152
if (!range || isNaN(totalSize)) {
@@ -148,27 +157,33 @@ export function applyRange(ctx: Koa.Context, totalSize=ctx.response.length) {
157
}
158
const [unit, ranges] = range.split('=')
159
if (unit !== 'bytes')
151
- return ctx.throw(HTTP_BAD_REQUEST, 'bad range unit')
160
+ return badRequest('bad range unit')
161
if (ranges?.includes(','))
153
- return ctx.throw(HTTP_BAD_REQUEST, 'multi-range not supported')
154
- let bytes = ranges?.split('-')
155
- if (!bytes?.length)
156
- return ctx.throw(HTTP_BAD_REQUEST, 'bad range')
162
+ return badRequest('multi-range not supported')
163
+ const bytes = ranges?.split('-')
164
+ if (bytes?.length !== 2)
165
+ return badRequest('bad range')
166
const max = totalSize - 1
158
- const start = bytes[0] ? Number(bytes[0]) : Math.max(0, totalSize-Number(bytes[1])) // a negative start is relative to the end
159
- const end = (bytes[0] && bytes[1]) ? Math.min(max, Number(bytes[1])) : max
167
+ const [startTxt, endTxt] = bytes
168
+ const start = startTxt ? Number(startTxt) : Math.max(0, totalSize-Number(endTxt)) // a negative start is relative to the end
169
+ const end = (startTxt && endTxt) ? Math.min(max, Number(endTxt)) : max
170
+ if (isNaN(start) || startTxt && endTxt && isNaN(end))
171
+ return badRequest('bad range')
172
// we don't support last-bytes without knowing max
161
- if (isNaN(end) && isNaN(max) || end > max || start > max) {
162
- ctx.status = HTTP_RANGE_NOT_SATISFIABLE
173
+ if (isNaN(end) && isNaN(max) || end > max || start > max || start > end) {
174
ctx.set('Content-Range', `bytes */${totalSize}`)
164
- ctx.body = 'Requested Range Not Satisfiable'
165
- return
175
+ return badRequest('Requested Range Not Satisfiable', HTTP_RANGE_NOT_SATISFIABLE)
176
}
177
ctx.state.includesLastByte = end === max
178
ctx.status = HTTP_PARTIAL_CONTENT
179
ctx.set('Content-Range', `bytes ${start}-${isNaN(end) ? '' : end}/${isNaN(totalSize) ? '*' : totalSize}`)
180
ctx.response.length = end - start + 1
181
return { start, end }
182
+
183
+ function badRequest(message: string, status=HTTP_BAD_REQUEST) {
184
+ ctx.status = status
185
+ ctx.body = message
186
+ }
187
}
188
189
function downloadLimiter<T>(configMax: { get: () => number | undefined }, cbKey: (ctx: Koa.Context) => T | undefined) {
tests/test.ts
+4
-6
@@ -65,12 +65,10 @@ describe('basics', () => {
65
throw "missing etag"
66
await req('/f1/f2/alfa.txt', /a[^d]+$/, { headers: { Range: 'bytes=0-2', 'If-Range': etag } })() // only "abc" is expected
67
})
68
- test('download.partial', req('/f1/f2/alfa.txt', /a[^d]+$/, { // only "abc" is expected
69
- headers: { Range: 'bytes=0-2' }
70
- }))
71
- test('bad range', req('/f1/f2/alfa.txt', 416, {
72
- headers: { Range: 'bytes=7-' }
73
- }))
68
+ test('download.partial', req('/f1/f2/alfa.txt', /a[^d]+$/, { headers: { Range: 'bytes=0-2' } })) // only "abc" is expected
69
+ test('bad range', req('/f1/f2/alfa.txt', 416, { headers: { Range: 'bytes=7-' } }))
70
+ test('bad range.inverted', req('/f1/f2/alfa.txt', 416, { headers: { Range: 'bytes=3-2' } }))
71
+ test('bad range.malformed', req('/f1/f2/alfa.txt', 400, { headers: { Range: 'bytes=abc-def' } }))
72
test('roots', req('/f2/alfa.txt', 200, { baseUrl: BASE_URL_127 })) // host 127.0.0.1 is rooted in /f1
73
test('website', req('/f1/page/', { re:/This is a test/, mime:'text/html' }))
74
test('traversal', req('/f1/page/.%2e/.%2e/README.md', 404))