Block SSRF on media_url downloads (transcribe-url/summarize-url)

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).
This commit is contained in:
Keysat
2026-06-13 16:23:26 -05:00
parent 0b90120b72
commit 8ad7c54da4
3 changed files with 255 additions and 2 deletions
+90
View File
@@ -0,0 +1,90 @@
// 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");
});
});