fix(export): clamp oversized media probe ranges

This commit is contained in:
webadderall
2026-05-17 17:38:45 +10:00
parent 6255890556
commit 7b137e0f13
2 changed files with 77 additions and 31 deletions
+26
View File
@@ -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,
});
});
});
+51 -31
View File
@@ -8,6 +8,48 @@ import { getMediaContentType } from "./mediaTypes";
let mediaServerBaseUrl: string | null = null;
let mediaServerStartPromise: Promise<string> | 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<string | null> {
try {
return await fs.realpath(path.resolve(filePath));
@@ -76,43 +118,21 @@ 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}` });
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) {
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 byteRange = resolveHttpByteRange(rangeHeader, fileSize);
if (!byteRange) {
response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` });
response.end();
return;
}
const { start, end } = byteRange;
const chunkSize = end - start + 1;
response.writeHead(206, {
...corsHeaders,