From b057374ea185be66c00b4cca91ae62c1196a7602 Mon Sep 17 00:00:00 2001 From: Maciej Dzierzek Date: Sun, 24 May 2026 10:29:59 +0200 Subject: [PATCH] fix(sirv): clamp Range `end` above MAX_SAFE_INTEGER to avoid crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A malformed `Range` header whose end value exceeds Number.MAX_SAFE_INTEGER (e.g. `bytes=0-18446744073709551615`, easily produced by bots / fuzz traffic) crashed the process with: RangeError [ERR_OUT_OF_RANGE]: The value of "end" is out of range. It must be >= 0 && <= 9007199254740991. at new ReadStream (node:internal/fs/streams:217:5) at send (packages/sirv/index.mjs:95:5) Root cause: although the local `end` variable was clamped to `stats.size - 1` when the parsed value exceeded the file size, the clamp did not update `opts.end`, which was assigned in the same declaration. The original (out-of-range) value was therefore still passed to `fs.createReadStream({ end })`, which validates synchronously against `Number.MAX_SAFE_INTEGER` and throws. This is the same class of bug as #140 / #147; that fix landed in the CJS `index.js` source in 2023 but was lost during the 2024-10 ESM-only refactor (commit f0113f3). Changes: - Reject non-safe-integer parses up front (treat as "absent" per RFC 7233 §3.1, falling back to start=0 / end=stats.size-1). - Re-assign `opts.end` together with the lexical `end` when the clamp fires, so `createReadStream` always receives a safe value. - Add a `start > end` check to the 416 branch so a swapped range cannot reach `createReadStream` with negative-length opts. Tests: - Regression test for `bytes=0-18446744073709551615` (reproduces the reported crash on the unpatched code). - Range start above MAX_SAFE_INTEGER falls back to a full-file 206. - Non-numeric range tokens (`bytes=abc-def`) fall back to a full-file 206 — documents existing behavior, kept stable. Verified by reverting `index.mjs` and observing the suite crash with the same `RangeError` reported in production, then re-applying the fix to confirm all 13 range tests (including the 3 new ones) pass. --- packages/sirv/index.mjs | 10 +++++---- tests/sirv.mjs | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/sirv/index.mjs b/packages/sirv/index.mjs index 2f866d5..a1e9126 100644 --- a/packages/sirv/index.mjs +++ b/packages/sirv/index.mjs @@ -73,14 +73,16 @@ function send(req, res, file, stats, headers) { if (req.headers.range) { code = 206; let [x, y] = req.headers.range.replace('bytes=', '').split('-'); - let end = opts.end = parseInt(y, 10) || stats.size - 1; - let start = opts.start = parseInt(x, 10) || 0; + let parsedY = parseInt(y, 10); + let parsedX = parseInt(x, 10); + let end = opts.end = (Number.isSafeInteger(parsedY) && parsedY > 0) ? parsedY : stats.size - 1; + let start = opts.start = (Number.isSafeInteger(parsedX) && parsedX >= 0) ? parsedX : 0; if (end >= stats.size) { - end = stats.size - 1; + end = opts.end = stats.size - 1; } - if (start >= stats.size) { + if (start >= stats.size || start > end) { res.setHeader('Content-Range', `bytes */${stats.size}`); res.statusCode = 416; return res.end(); diff --git a/tests/sirv.mjs b/tests/sirv.mjs index b18db63..a00bb7c 100644 --- a/tests/sirv.mjs +++ b/tests/sirv.mjs @@ -1100,6 +1100,52 @@ ranges('should shrink range end if it cannot be met (overflow)', async () => { } }); +// Regression: a malformed `Range` end larger than `Number.MAX_SAFE_INTEGER` +// (e.g. `2^64-1` from a bot probe) used to crash the process because the +// lexical `end` variable was clamped but `opts.end` retained the unsafe +// value, which `fs.createReadStream({ end })` rejects with `RangeError`. +ranges('should clamp range end above MAX_SAFE_INTEGER without crashing', async () => { + let server = utils.http(); + + try { + let headers = { Range: 'bytes=0-18446744073709551615' }; + let file = await utils.lookup('bundle.67329.js', 'utf8'); + let res = await server.send('GET', '/bundle.67329.js', { headers }); + assert.is(res.statusCode, 206); + assert.is(res.headers['content-range'], `bytes 0-${file.size - 1}/${file.size}`); + } finally { + server.close(); + } +}); + +ranges('should fall back to a full-file response when range start is above MAX_SAFE_INTEGER', async () => { + let server = utils.http(); + + try { + let headers = { Range: 'bytes=18446744073709551615-' }; + let file = await utils.lookup('bundle.67329.js', 'utf8'); + let res = await server.send('GET', '/bundle.67329.js', { headers }); + assert.is(res.statusCode, 206); + assert.is(res.headers['content-range'], `bytes 0-${file.size - 1}/${file.size}`); + } finally { + server.close(); + } +}); + +ranges('should treat a non-numeric range as a full-file request', async () => { + let server = utils.http(); + + try { + let headers = { Range: 'bytes=abc-def' }; + let file = await utils.lookup('bundle.67329.js', 'utf8'); + let res = await server.send('GET', '/bundle.67329.js', { headers }); + assert.is(res.statusCode, 206); + assert.is(res.headers['content-range'], `bytes 0-${file.size - 1}/${file.size}`); + } finally { + server.close(); + } +}); + ranges('should not mutate response headers on subsequent non-Range requests :: dev', async () => { let server = utils.http({ dev: true });