From 7b137e0f131793a8d286e3f6e765bbc17a4c83eb Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 17 May 2026 17:38:45 +1000 Subject: [PATCH 1/2] fix(export): clamp oversized media probe ranges --- electron/mediaServer.test.ts | 26 +++++++++++++ electron/mediaServer.ts | 74 +++++++++++++++++++++++------------- 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/electron/mediaServer.test.ts b/electron/mediaServer.test.ts index f0effc57b..b06587e9f 100644 --- a/electron/mediaServer.test.ts +++ b/electron/mediaServer.test.ts @@ -70,3 +70,29 @@ describe("media server path policy", () => { expect(isAllowedMediaPath(missingPath)).toBe(false); }); }); + +describe("resolveHttpByteRange", () => { + it("clamps oversized explicit end offsets to EOF", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=0-9999999999", 3_221_225_472)).toEqual({ + start: 0, + end: 3_221_225_471, + }); + }); + + it("rejects ranges that start beyond EOF", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=500-999", 500)).toBeNull(); + }); + + it("preserves suffix range semantics", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=-500", 1_000)).toEqual({ + start: 500, + end: 999, + }); + }); +}); diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts index 201e58aa6..41e1774d6 100644 --- a/electron/mediaServer.ts +++ b/electron/mediaServer.ts @@ -8,6 +8,48 @@ import { getMediaContentType } from "./mediaTypes"; let mediaServerBaseUrl: string | null = null; let mediaServerStartPromise: Promise | null = null; +export function resolveHttpByteRange( + rangeHeader: string, + fileSize: number, +): { start: number; end: number } | null { + const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); + if (!match || (!match[1] && !match[2])) { + return null; + } + + if (fileSize === 0) { + return null; + } + + if (!match[1] && match[2]) { + // Suffix range: bytes=-500 + const suffixLength = Number.parseInt(match[2], 10); + if (Number.isNaN(suffixLength) || suffixLength <= 0) { + return null; + } + + return { + start: Math.max(0, fileSize - suffixLength), + end: fileSize - 1, + }; + } + + const start = Number.parseInt(match[1], 10); + if (Number.isNaN(start) || start < 0 || start >= fileSize) { + return null; + } + + const requestedEnd = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1; + if (Number.isNaN(requestedEnd) || requestedEnd < start) { + return null; + } + + return { + start, + end: Math.min(requestedEnd, fileSize - 1), + }; +} + async function resolveRealPath(filePath: string): Promise { try { return await fs.realpath(path.resolve(filePath)); @@ -76,42 +118,20 @@ async function handleMediaRequest( } if (rangeHeader) { - const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); - if (!match || (!match[1] && !match[2])) { - response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); + if (fileSize === 0) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` }); response.end(); return; } - let start: number; - let end: number; - - if (!match[1] && match[2]) { - // Suffix range: bytes=-500 - const suffixLength = Number.parseInt(match[2], 10); - if (Number.isNaN(suffixLength) || suffixLength <= 0) { - response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); - response.end(); - return; - } - start = Math.max(0, fileSize - suffixLength); - end = fileSize - 1; - } else { - start = Number.parseInt(match[1], 10); - end = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1; - } - - if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= fileSize || end >= fileSize) { + const byteRange = resolveHttpByteRange(rangeHeader, fileSize); + if (!byteRange) { response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); response.end(); return; } - if (fileSize === 0) { - response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` }); - response.end(); - return; - } + const { start, end } = byteRange; const chunkSize = end - start + 1; response.writeHead(206, { From 9876b8c20b2cfeb3ef2568164d971cb0e9bafcdf Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 17 May 2026 18:04:46 +1000 Subject: [PATCH 2/2] fix(media-server): reject malformed range headers --- electron/mediaServer.test.ts | 7 +++++++ electron/mediaServer.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/electron/mediaServer.test.ts b/electron/mediaServer.test.ts index b06587e9f..cf6cd01ba 100644 --- a/electron/mediaServer.test.ts +++ b/electron/mediaServer.test.ts @@ -72,6 +72,13 @@ describe("media server path policy", () => { }); describe("resolveHttpByteRange", () => { + it("rejects malformed and multi-range headers", async () => { + const { resolveHttpByteRange } = await import("./mediaServer"); + + expect(resolveHttpByteRange("bytes=0-1,2-3", 100)).toBeNull(); + expect(resolveHttpByteRange("bytes=0-1foo", 100)).toBeNull(); + }); + it("clamps oversized explicit end offsets to EOF", async () => { const { resolveHttpByteRange } = await import("./mediaServer"); diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts index 41e1774d6..c979a84e8 100644 --- a/electron/mediaServer.ts +++ b/electron/mediaServer.ts @@ -12,7 +12,7 @@ export function resolveHttpByteRange( rangeHeader: string, fileSize: number, ): { start: number; end: number } | null { - const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); + const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/); if (!match || (!match[1] && !match[2])) { return null; }