diff --git a/electron/mediaServer.test.ts b/electron/mediaServer.test.ts index f0effc57b..cf6cd01ba 100644 --- a/electron/mediaServer.test.ts +++ b/electron/mediaServer.test.ts @@ -70,3 +70,36 @@ describe("media server path policy", () => { expect(isAllowedMediaPath(missingPath)).toBe(false); }); }); + +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"); + + 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..c979a84e8 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.trim().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, {