fix: block SSRF on webmention path (target-domain check + DNS-resolving IP guard)
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.
This commit is contained in:
@@ -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<void>} 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<Response>} 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");
|
||||||
|
}
|
||||||
@@ -24,9 +24,10 @@ export async function receive(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate URLs
|
// Validate URLs
|
||||||
|
let targetUrl;
|
||||||
try {
|
try {
|
||||||
new URL(source);
|
new URL(source);
|
||||||
new URL(target);
|
targetUrl = new URL(target);
|
||||||
} catch {
|
} catch {
|
||||||
return response.status(400).json({
|
return response.status(400).json({
|
||||||
error: "invalid_request",
|
error: "invalid_request",
|
||||||
@@ -35,6 +36,27 @@ export async function receive(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { application } = request.app.locals;
|
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);
|
const userId = getUserId(request);
|
||||||
|
|
||||||
// Return 202 Accepted immediately (processing asynchronously)
|
// Return 202 Accepted immediately (processing asynchronously)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { mf2 } from "microformats-parser";
|
import { mf2 } from "microformats-parser";
|
||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
|
||||||
|
import { safeFetch } from "../utils/ssrf-guard.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize HTML options (matches normalizer.js)
|
* Sanitize HTML options (matches normalizer.js)
|
||||||
*/
|
*/
|
||||||
@@ -36,13 +38,16 @@ const SANITIZE_OPTIONS = {
|
|||||||
*/
|
*/
|
||||||
export async function verifyWebmention(source, target) {
|
export async function verifyWebmention(source, target) {
|
||||||
try {
|
try {
|
||||||
// Fetch the source URL
|
// Fetch the source URL with SSRF protection.
|
||||||
const response = await fetch(source, {
|
// 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: {
|
headers: {
|
||||||
Accept: "text/html, application/xhtml+xml",
|
Accept: "text/html, application/xhtml+xml",
|
||||||
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
||||||
},
|
},
|
||||||
redirect: "follow",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user