You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Security] Static-file Range requests call readFileBytes on the whole file and answer with a subarray view, so Range: bytes=0-0 buffers and retains up to the 50 MiB maxFileSize per in-flight request #969
Component:src/http/static/StaticFiles.ts Severity (assessment): MEDIUM CWE: CWE-400 (Uncontrolled Resource Consumption)
The Range branch of serveResolvedFile calls readFileBytes(fsPath) — which reads the entire file — and then answers with bytes.subarray(start, end + 1). A one-byte range therefore costs a full file read, and because subarray returns a view rather than a copy, the response body keeps the whole ArrayBuffer alive for as long as the backend holds it. With the 50 MiB maxFileSize default, Range: bytes=0-0 against a large asset buffers and retains up to 50 MiB to serve one byte. Ranges are on by default (ranges: true), and the route advertises Accept-Ranges: bytes, so this is the shape the framework invites clients to use. The mechanism inverts the point of range requests: they exist so a client can ask for less, and here asking for less costs the server exactly as much while making the amplification ratio arbitrary.
Exploit walkthrough
Attacker position: remote unauthenticated — any client that can GET a route mounted with getFromDirectory / getFromFile, which is the documented way to serve a SPA bundle, media, or downloads.
Find the largest asset under the mount (or just any asset; the ratio is what matters, not the absolute size). A 40 MiB video or a 20 MiB source map is ordinary.
Issue GET /assets/video.mp4 with Range: bytes=0-0. The server reads 40 MiB, allocates a 40 MiB Uint8Array, and hands back a 1-byte view of it.
Repeat concurrently. Each in-flight request holds its own copy — the reads are independent, nothing is shared or cached — so n concurrent one-byte requests pin n × filesize.
The request cost to the attacker is one line of headers and one byte of response, which means ordinary rate limits keyed on bytes served, response size or connection count do not see it. The content-length: 1 in the reply is what any proxy in front will meter.
Eight sequential one-byte requests against a 40 MiB file already pinned 320 MiB in the measurement below; there is nothing in the path that caps the sum.
Evidence — src/http/static/StaticFiles.ts:106-128
src/http/static/StaticFiles.ts:106-128constrangeHeader=settings.ranges ? request.headers['range'] : undefined;if(rangeHeader!==undefined){constifRange=request.headers['if-range'];// A weak ETag can never satisfy If-Range; only an exact Last-Modified match does.consthonourRange=ifRange===undefined||ifRange===lastModified;if(honourRange){constparsed=parseRange(rangeHeader,stat.size);if(parsed==='unsatisfiable'){return{status: 416,headers: { ...headers,'content-range': `bytes */${stat.size}`}, contentType,body: null};}if(parsed){constlength=parsed.end-parsed.start+1;constrangeHeaders={
...headers,'content-range': `bytes ${parsed.start}-${parsed.end}/${stat.size}`,'content-length': String(length),};if(isHead)return{status: 206,headers: rangeHeaders, contentType,body: null};constbytes=awaitreadFileBytes(fsPath);return{status: 206,headers: rangeHeaders, contentType,body: bytes.subarray(parsed.start,parsed.end+1)};}}}
length is computed and put in the header; it is never used to bound the read.
Evidence — src/http/static/fsAccess.ts:51-55
src/http/static/fsAccess.ts:51-55/** Read the whole file into a Uint8Array (bounded by the caller's maxFileSize). */exportasyncfunctionreadFileBytes(path: string): Promise<Uint8Array>{constbuffer=await(awaitfsp()).readFile(path);returnnewUint8Array(buffer.buffer,buffer.byteOffset,buffer.byteLength);}
There is no offset/length overload; the module offers no way to read part of a file. The header of the same file names the gap and defers it:
src/http/static/fsAccess.ts:1-6/** * Cross-runtime filesystem access for static serving. `node:fs/promises` * is lazy-imported (cached) and works on Bun, Node, and Deno via their * node-compat layers — no per-runtime adapter needed while bodies are * buffered (a future streaming path could add one behind this module). */
maxFileSize bounds one read, not the sum. It is checked once per request (stat.size > settings.maxFileSize → 413) and says nothing about concurrency. It is also the upper bound on the amplification, not a defence against it: a deployment that raises it for legitimate large assets raises the per-request cost proportionally.
subarray makes it worse than a transient spike. A copy would at least let the 40 MiB be collected as soon as the handler returns. A view keeps the backing ArrayBuffer reachable for the whole response lifetime, so the retention lasts as long as the socket write does — precisely the window an attacker controls by reading slowly.
The HEAD fast path proves the shape is known.if (isHead) return … body: null skips the read entirely, so the code already distinguishes "needs bytes" from "does not" — it just has no way to ask for some bytes.
parseRange is not the problem. It clamps correctly (start > end || start >= size → unsatisfiable, end >= size → size - 1); the parsed range is simply never used to limit the read.
Suggested fix
Give fsAccess a bounded read — readFileRange(path, start, length) over filehandle.read(buffer, 0, length, start) — and have the Range branch call it. node:fs/promises' FileHandle.read is available on Bun, Node and Deno's compat layer, so this needs no per-runtime adapter, matching the module's existing note.
Copy rather than view on the full-body path too, or keep the view but document that the body retains the file; the range path should return an exact-length buffer either way.
Cap the concurrent in-flight buffered bytes for a static mount (a simple counter with a configurable ceiling, 413 or 503 past it) so the sum is bounded even after the per-request fix.
Range: bytes=0-0 against a 40 MiB asset reads and allocates on the order of the requested length, not the file size.
The 206 body's backing buffer is exactly the served length.
Suffix ranges (bytes=-N), open-ended ranges (bytes=N-), 416 and If-Range behaviour are unchanged — existing StaticFiles.test.ts cases stay green on all three backends.
A test asserts the allocation bound directly (e.g. body.buffer.byteLength), not indirectly through RSS.
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution. The route was compiled with compile(getFromFile(asset)) and its handler invoked directly, against a 40 MiB file with default options:
asset: 40 MiB, default maxFileSize = 50 MiB
Range: bytes=0-0 status 206 | bytes served 1 | backing ArrayBuffer 41943040 | amplification 41943040x
Range: bytes=100-199 status 206 | bytes served 100 | backing ArrayBuffer 41943040 | amplification 419430x
Range: bytes=-1 (last byte) status 206 | bytes served 1 | backing ArrayBuffer 41943040 | amplification 41943040x
no Range (full 200) status 200 | bytes served 41943040 | backing ArrayBuffer 41943040 | amplification 1x
HEAD + Range (no read) status 206 | bytes served 0 | backing ArrayBuffer 0 | amplification n/a
8 concurrent one-byte 206 responses -> 8 bytes served,
320 MiB of ArrayBuffer retained, rss delta 321.3 MiB
backing ArrayBuffer is body.buffer.byteLength — a structural fact about the returned view, not a memory estimate. The rss figure is corroboration only.
Part of the production-readiness review batch — tracked in #913.
Component:
src/http/static/StaticFiles.tsSeverity (assessment): MEDIUM
CWE: CWE-400 (Uncontrolled Resource Consumption)
The Range branch of
serveResolvedFilecallsreadFileBytes(fsPath)— which reads the entire file — and then answers withbytes.subarray(start, end + 1). A one-byte range therefore costs a full file read, and becausesubarrayreturns a view rather than a copy, the response body keeps the wholeArrayBufferalive for as long as the backend holds it. With the 50 MiBmaxFileSizedefault,Range: bytes=0-0against a large asset buffers and retains up to 50 MiB to serve one byte. Ranges are on by default (ranges: true), and the route advertisesAccept-Ranges: bytes, so this is the shape the framework invites clients to use. The mechanism inverts the point of range requests: they exist so a client can ask for less, and here asking for less costs the server exactly as much while making the amplification ratio arbitrary.Exploit walkthrough
Attacker position: remote unauthenticated — any client that can
GETa route mounted withgetFromDirectory/getFromFile, which is the documented way to serve a SPA bundle, media, or downloads.GET /assets/video.mp4withRange: bytes=0-0. The server reads 40 MiB, allocates a 40 MiBUint8Array, and hands back a 1-byte view of it.content-length: 1in the reply is what any proxy in front will meter.Eight sequential one-byte requests against a 40 MiB file already pinned 320 MiB in the measurement below; there is nothing in the path that caps the sum.
Evidence —
src/http/static/StaticFiles.ts:106-128lengthis computed and put in the header; it is never used to bound the read.Evidence —
src/http/static/fsAccess.ts:51-55There is no offset/length overload; the module offers no way to read part of a file. The header of the same file names the gap and defers it:
Evidence —
src/http/static/StaticFilesOptions.ts:119-124Ranges default on, and the cap that bounds a single read is 50 MiB:
Why the existing guard does not cover it
maxFileSizebounds one read, not the sum. It is checked once per request (stat.size > settings.maxFileSize → 413) and says nothing about concurrency. It is also the upper bound on the amplification, not a defence against it: a deployment that raises it for legitimate large assets raises the per-request cost proportionally.subarraymakes it worse than a transient spike. A copy would at least let the 40 MiB be collected as soon as the handler returns. A view keeps the backingArrayBufferreachable for the whole response lifetime, so the retention lasts as long as the socket write does — precisely the window an attacker controls by reading slowly.HEADfast path proves the shape is known.if (isHead) return … body: nullskips the read entirely, so the code already distinguishes "needs bytes" from "does not" — it just has no way to ask for some bytes.parseRangeis not the problem. It clamps correctly (start > end || start >= size → unsatisfiable,end >= size → size - 1); the parsed range is simply never used to limit the read.Suggested fix
fsAccessa bounded read —readFileRange(path, start, length)overfilehandle.read(buffer, 0, length, start)— and have the Range branch call it.node:fs/promises'FileHandle.readis available on Bun, Node and Deno's compat layer, so this needs no per-runtime adapter, matching the module's existing note.Acceptance criteria
Range: bytes=0-0against a 40 MiB asset reads and allocates on the order of the requested length, not the file size.bytes=-N), open-ended ranges (bytes=N-), 416 andIf-Rangebehaviour are unchanged — existingStaticFiles.test.tscases stay green on all three backends.body.buffer.byteLength), not indirectly through RSS.HEADwith a Range still performs no read.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: reproduced by execution. The route was compiled withcompile(getFromFile(asset))and its handler invoked directly, against a 40 MiB file with default options:backing ArrayBufferisbody.buffer.byteLength— a structural fact about the returned view, not a memory estimate. The rss figure is corroboration only.Part of the production-readiness review batch — tracked in #913.