From d3d09863b583ac87a040c1e5e3fb097b03c3460c Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:07:11 +0200 Subject: [PATCH] fix: block SSRF on webmention path (target-domain check + DNS-resolving IP guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/utils/ssrf-guard.js | 141 +++++++++++++++++++++++++++++++++++++ lib/webmention/receiver.js | 24 ++++++- lib/webmention/verifier.js | 11 ++- test/ssrf-guard.test.js | 52 ++++++++++++++ 4 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 lib/utils/ssrf-guard.js create mode 100644 test/ssrf-guard.test.js diff --git a/lib/utils/ssrf-guard.js b/lib/utils/ssrf-guard.js new file mode 100644 index 0000000..25a8083 --- /dev/null +++ b/lib/utils/ssrf-guard.js @@ -0,0 +1,141 @@ +/** + * SSRF guard — resolved-IP based, rebinding-resistant + * @module utils/ssrf-guard + * + * Unlike a hostname string-prefix check, this resolves the host via DNS and + * inspects every resolved address. A hostname whose A-record points at an + * internal IP (DNS rebinding) is therefore caught. IPv6, IPv4-mapped IPv6, + * and multi-A records are all handled. + */ + +import dns from "node:dns/promises"; +import net from "node:net"; + +/** + * Decide whether a resolved IP address is private/internal and must not be fetched. + * @param {string} ip - A resolved IP literal (IPv4 or IPv6) + * @returns {boolean} True if the address is internal and must be blocked + */ +export function isPrivateIp(ip) { + const family = net.isIP(ip); + if (family === 0) return true; // not a valid IP literal → block defensively + + if (family === 4) return isPrivateIpv4(ip); + + // IPv6 + const lower = ip.toLowerCase(); + + // Loopback ::1 and unspecified :: + if (lower === "::1" || lower === "::") return true; + + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible — extract the v4 tail + const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateIpv4(mapped[1]); + + // Link-local fe80::/10 + if (lower.startsWith("fe8") || lower.startsWith("fe9") || + lower.startsWith("fea") || lower.startsWith("feb")) return true; + + // Unique-local fc00::/7 (fc.. and fd..) + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + + return false; +} + +/** + * Check a single IPv4 literal against private/reserved ranges. + * @param {string} ip - IPv4 literal + * @returns {boolean} True if internal + */ +function isPrivateIpv4(ip) { + const parts = ip.split(".").map((n) => Number.parseInt(n, 10)); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) { + return true; // malformed → block + } + const [a, b] = parts; + + if (a === 0) return true; // 0.0.0.0/8 "this network" + if (a === 10) return true; // 10.0.0.0/8 RFC1918 + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (cloud metadata) + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918 + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + + return false; +} + +/** + * Assert that a URL is safe to fetch (public scheme + public resolved host). + * Throws an Error if the URL targets an internal/private address or uses a + * non-HTTP(S) scheme. + * @param {string} urlString - URL to validate + * @returns {Promise} Resolves if safe; rejects otherwise + */ +export async function assertPublicUrl(urlString) { + let parsed; + try { + parsed = new URL(urlString); + } catch { + throw new Error("Blocked: invalid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Blocked: non-HTTP scheme ${parsed.protocol}`); + } + + const host = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + + // If the host is already an IP literal, check it directly (no DNS needed). + if (net.isIP(host) !== 0) { + if (isPrivateIp(host)) throw new Error(`Blocked: private address ${host}`); + return; + } + + // Resolve ALL addresses and block if ANY is private (rebinding-resistant). + let addresses; + try { + addresses = await dns.lookup(host, { all: true }); + } catch { + throw new Error(`Blocked: DNS resolution failed for ${host}`); + } + + if (addresses.length === 0) throw new Error(`Blocked: no addresses for ${host}`); + + for (const { address } of addresses) { + if (isPrivateIp(address)) { + throw new Error(`Blocked: ${host} resolves to private address ${address}`); + } + } +} + +/** + * Fetch a URL with SSRF protection across redirects. + * Follows up to maxRedirects hops, re-validating each hop's URL before fetching. + * @param {string} urlString - Initial URL + * @param {object} [options] - fetch options (redirect is forced to "manual") + * @param {number} [maxRedirects] - Max redirect hops to follow (default 5) + * @returns {Promise} The final response + */ +export async function safeFetch(urlString, options = {}, maxRedirects = 5) { + let currentUrl = urlString; + + for (let hop = 0; hop <= maxRedirects; hop++) { + await assertPublicUrl(currentUrl); // guard EVERY hop, including post-redirect + + const response = await fetch(currentUrl, { ...options, redirect: "manual" }); + + // Not a redirect → this is the final response. + if (response.status < 300 || response.status >= 400) { + return response; + } + + const location = response.headers.get("location"); + if (!location) return response; // redirect without Location — return as-is + + currentUrl = new URL(location, currentUrl).toString(); // resolve relative redirects + } + + throw new Error("Blocked: too many redirects"); +} diff --git a/lib/webmention/receiver.js b/lib/webmention/receiver.js index 0a6dfe8..4eabd8b 100644 --- a/lib/webmention/receiver.js +++ b/lib/webmention/receiver.js @@ -24,9 +24,10 @@ export async function receive(request, response) { } // Validate URLs + let targetUrl; try { new URL(source); - new URL(target); + targetUrl = new URL(target); } catch { return response.status(400).json({ error: "invalid_request", @@ -35,6 +36,27 @@ export async function receive(request, response) { } const { application } = request.app.locals; + + // Per the Webmention spec, target MUST be a resource on this site. Rejecting + // foreign targets stops the endpoint being abused as an open relay/SSRF + // pivot toward arbitrary hosts. Only enforced when the publication URL is + // known (single-user `publication.me`); if unknown, the source-fetch SSRF + // guard in the verifier still protects against internal probing. + const me = application?.publication?.me; + if (me) { + try { + const meHost = new URL(me).host; + if (targetUrl.host !== meHost) { + return response.status(400).json({ + error: "invalid_request", + error_description: "Target is not on this site", + }); + } + } catch { + // publication.me malformed — fall through; source guard still applies. + } + } + const userId = getUserId(request); // Return 202 Accepted immediately (processing asynchronously) diff --git a/lib/webmention/verifier.js b/lib/webmention/verifier.js index f4fa377..d6f0250 100644 --- a/lib/webmention/verifier.js +++ b/lib/webmention/verifier.js @@ -6,6 +6,8 @@ import { mf2 } from "microformats-parser"; import sanitizeHtml from "sanitize-html"; +import { safeFetch } from "../utils/ssrf-guard.js"; + /** * Sanitize HTML options (matches normalizer.js) */ @@ -36,13 +38,16 @@ const SANITIZE_OPTIONS = { */ export async function verifyWebmention(source, target) { try { - // Fetch the source URL - const response = await fetch(source, { + // Fetch the source URL with SSRF protection. + // safeFetch resolves DNS and rejects internal/private addresses on the + // initial URL AND on every redirect hop (rebinding-resistant). It replaces + // a bare fetch(redirect:"follow") that let an attacker-supplied source + // reach RFC1918 / loopback / link-local services. + const response = await safeFetch(source, { headers: { Accept: "text/html, application/xhtml+xml", "User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)", }, - redirect: "follow", }); if (!response.ok) { diff --git a/test/ssrf-guard.test.js b/test/ssrf-guard.test.js new file mode 100644 index 0000000..7ed928a --- /dev/null +++ b/test/ssrf-guard.test.js @@ -0,0 +1,52 @@ +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")); +});