8ad7c54da4
downloadDirect fetched any caller-supplied media_url with redirect-follow and no host/scheme validation; the route is reachable via a self-chosen X-Recap-Install-Id, so a caller could probe the operator's LAN or cloud metadata (169.254.169.254). Add safe-url.js: assertPublicHttpUrl rejects non-http(s) schemes and hosts resolving to private/loopback/link-local/ reserved ranges, and safeFetch follows redirects manually, re-validating each hop. Route downloadDirect through it (covers transcribe-url, summarize-url, and admin-test-run).
91 lines
2.5 KiB
JavaScript
91 lines
2.5 KiB
JavaScript
// SSRF guard for user-supplied media URLs (safe-url.js). Uses literal
|
|
// IPs so the address checks need no DNS / network.
|
|
|
|
import { test, describe } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
isBlockedAddress,
|
|
assertPublicHttpUrl,
|
|
BlockedUrlError,
|
|
} from "../safe-url.js";
|
|
|
|
describe("isBlockedAddress", () => {
|
|
test("blocks private / loopback / link-local / reserved IPv4", () => {
|
|
for (const ip of [
|
|
"127.0.0.1",
|
|
"10.0.0.5",
|
|
"172.16.0.1",
|
|
"172.31.255.255",
|
|
"192.168.1.1",
|
|
"169.254.169.254", // cloud metadata
|
|
"100.64.0.1",
|
|
"0.0.0.0",
|
|
"198.18.0.1",
|
|
"224.0.0.1",
|
|
"255.255.255.255",
|
|
]) {
|
|
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
|
|
}
|
|
});
|
|
|
|
test("allows public IPv4 (incl. the /12 boundaries around 172.16/12)", () => {
|
|
for (const ip of ["8.8.8.8", "1.1.1.1", "172.15.0.1", "172.32.0.1", "93.184.216.34"]) {
|
|
assert.equal(isBlockedAddress(ip), false, `${ip} should be allowed`);
|
|
}
|
|
});
|
|
|
|
test("blocks loopback / ULA / link-local / IPv4-mapped IPv6", () => {
|
|
for (const ip of [
|
|
"::1",
|
|
"::",
|
|
"fe80::1",
|
|
"febf::1",
|
|
"fc00::1",
|
|
"fd12:3456::1",
|
|
"ff02::1",
|
|
"::ffff:127.0.0.1",
|
|
"::ffff:192.168.0.1",
|
|
]) {
|
|
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
|
|
}
|
|
});
|
|
|
|
test("allows public IPv6", () => {
|
|
assert.equal(isBlockedAddress("2606:4700:4700::1111"), false);
|
|
});
|
|
});
|
|
|
|
describe("assertPublicHttpUrl", () => {
|
|
test("rejects non-http(s) schemes", async () => {
|
|
for (const u of [
|
|
"file:///etc/passwd",
|
|
"gopher://x/_",
|
|
"ftp://h/f",
|
|
"data:text/plain,hi",
|
|
]) {
|
|
await assert.rejects(() => assertPublicHttpUrl(u), BlockedUrlError);
|
|
}
|
|
});
|
|
|
|
test("rejects literal private / metadata IP hosts (no DNS needed)", async () => {
|
|
for (const u of [
|
|
"http://127.0.0.1/x",
|
|
"http://169.254.169.254/latest/meta-data/",
|
|
"http://[::1]/x",
|
|
"http://192.168.0.10:9000/a",
|
|
"https://10.1.2.3/audio.mp3",
|
|
]) {
|
|
await assert.rejects(() => assertPublicHttpUrl(u), BlockedUrlError);
|
|
}
|
|
});
|
|
|
|
test("rejects malformed URLs", async () => {
|
|
await assert.rejects(() => assertPublicHttpUrl("not a url"), BlockedUrlError);
|
|
});
|
|
|
|
test("allows a public literal IP host", async () => {
|
|
const u = await assertPublicHttpUrl("https://8.8.8.8/audio.mp3");
|
|
assert.equal(u.hostname, "8.8.8.8");
|
|
});
|
|
});
|