d3d09863b5
POST /microsub/webmention fetched the attacker-supplied source URL with redirect:follow and no IP guard, and never checked that target was on this site — an unauthenticated SSRF reaching RFC1918/loopback/link-local. - new lib/utils/ssrf-guard.js: assertPublicUrl resolves DNS and rejects private/loopback/link-local/IPv6-ULA/IPv4-mapped on the resolved IP (rebinding-resistant, unlike the prior string-prefix isPrivateUrl); safeFetch re-guards every redirect hop. - verifier.js: source fetch routed through safeFetch. - receiver.js: reject target not on publication.me host (spec-required; closes open-relay angle). No auth gate added — webmention stays an open protocol. - test/ssrf-guard.test.js: 7 cases, internal blocked + public accepted.
53 lines
2.1 KiB
JavaScript
53 lines
2.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
import { assertPublicUrl, isPrivateIp } from "../lib/utils/ssrf-guard.js";
|
|
|
|
test("isPrivateIp blocks IPv4 internal ranges", () => {
|
|
for (const ip of [
|
|
"10.100.0.5", // RFC1918 (Sven's LDAP admin)
|
|
"127.0.0.1", // loopback
|
|
"169.254.169.254", // link-local / cloud metadata
|
|
"172.16.0.1", "172.31.255.255", // RFC1918 172.16/12
|
|
"192.168.1.1", // RFC1918
|
|
"0.0.0.0", // this-network
|
|
"100.64.0.1", // CGNAT
|
|
]) {
|
|
assert.equal(isPrivateIp(ip), true, `${ip} should be private`);
|
|
}
|
|
});
|
|
|
|
test("isPrivateIp allows public IPv4", () => {
|
|
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(isPrivateIp(ip), false, `${ip} should be public`);
|
|
}
|
|
});
|
|
|
|
test("isPrivateIp blocks IPv6 internal + IPv4-mapped", () => {
|
|
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1", "::ffff:127.0.0.1", "::ffff:10.0.0.1"]) {
|
|
assert.equal(isPrivateIp(ip), true, `${ip} should be private`);
|
|
}
|
|
});
|
|
|
|
test("isPrivateIp allows public IPv6 + mapped public", () => {
|
|
assert.equal(isPrivateIp("2606:4700:4700::1111"), false);
|
|
assert.equal(isPrivateIp("::ffff:8.8.8.8"), false);
|
|
});
|
|
|
|
test("assertPublicUrl rejects internal IP literals", async () => {
|
|
await assert.rejects(() => assertPublicUrl("http://10.100.0.5/"), /private address/);
|
|
await assert.rejects(() => assertPublicUrl("http://localhost:6379/"), /private address|DNS/);
|
|
await assert.rejects(() => assertPublicUrl("http://127.0.0.1/"), /private address/);
|
|
await assert.rejects(() => assertPublicUrl("http://[::1]/"), /private address/);
|
|
});
|
|
|
|
test("assertPublicUrl rejects non-HTTP schemes", async () => {
|
|
await assert.rejects(() => assertPublicUrl("file:///etc/passwd"), /non-HTTP/);
|
|
await assert.rejects(() => assertPublicUrl("gopher://10.0.0.1/"), /non-HTTP/);
|
|
});
|
|
|
|
test("assertPublicUrl accepts a public host", async () => {
|
|
// example.com resolves to a public address; legit webmention sources look like this.
|
|
await assert.doesNotReject(() => assertPublicUrl("https://example.com/post"));
|
|
});
|