Skip to content

[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

Description

@pathosDev

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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-128
  const rangeHeader = settings.ranges ? request.headers['range'] : undefined;
  if (rangeHeader !== undefined) {
    const ifRange = request.headers['if-range'];
    // A weak ETag can never satisfy If-Range; only an exact Last-Modified match does.
    const honourRange = ifRange === undefined || ifRange === lastModified;
    if (honourRange) {
      const parsed = parseRange(rangeHeader, stat.size);
      if (parsed === 'unsatisfiable') {
        return { status: 416, headers: { ...headers, 'content-range': `bytes */${stat.size}` }, contentType, body: null };
      }
      if (parsed) {
        const length = parsed.end - parsed.start + 1;
        const rangeHeaders = {
          ...headers,
          'content-range': `bytes ${parsed.start}-${parsed.end}/${stat.size}`,
          'content-length': String(length),
        };
        if (isHead) return { status: 206, headers: rangeHeaders, contentType, body: null };
        const bytes = await readFileBytes(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). */
export async function readFileBytes(path: string): Promise<Uint8Array> {
  const buffer = await (await fsp()).readFile(path);
  return new Uint8Array(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).
 */

Evidence — src/http/static/StaticFilesOptions.ts:119-124

Ranges default on, and the cap that bounds a single read is 50 MiB:

src/http/static/StaticFilesOptions.ts:119-124
    ranges: resolvedOptions.ranges ?? true,
    dotfiles: resolvedOptions.dotfiles ?? 'deny',
    symlinks: resolvedOptions.symlinks ?? 'within-root',
    contentTypes: resolvedOptions.contentTypes,
    contentType: resolvedOptions.contentType,
    maxFileSize: resolvedOptions.maxFileSize ?? 50 * 1024 * 1024,

Why the existing guard does not cover it

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.
  • Streaming ([Feature] Stream large static files without buffering into memory #465) subsumes both and is the right end state; the bounded read is the small change that removes the amplification now.

Acceptance criteria

  • 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.
  • HEAD with a Range still performs no read.
  • [Feature] Range requests / byte serving for static files (Accept-Ranges, 206, If-Range) #809 is closed as already-implemented with a pointer here, so the stale feature request does not mask the defect.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions