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))