From 6a1d55b83613a2eed3ea6551853febdbb4479a67 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:58:24 +0200 Subject: [PATCH] security: SSRF guard on federation dereferences (own-host allowlist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation runs with allowPrivateAddress:true (own host svemagie.net resolves to a LAN RFC-1918 address, required for own-site federation), which disables Fedify's blanket SSRF guard. Attacker-sourced URLs (quoteUrl, author URLs, etc.) flowing through lookupWithSecurity could reach internal services. - new lib/ssrf-guard.js: assertLookupAllowed(url, ownHost) resolves DNS and blocks private/reserved resolved IPs UNLESS the hostname string-equals the trusted publication host (allowlist by hostname, NOT resolved IP — the LAN private IP is shared). Covers 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 0/8, 100.64/10, IPv6 ULA/link-local/::1/IPv4-mapped. Fails closed on DNS error. - lookupWithSecurity: optional options.ownHost (no call-site signature change); guards http(s) URL inputs, returns null on block (existing contract). - og-unfurl fetchAndStoreQuote: passes ownHost so remote quotes of our own posts still resolve. - profile.remote.js: followController string-prefix check replaced with the DNS-resolving guard; unfollowController gains validation (had none). - test/ssrf-guard.test.js: 8 cases incl. own-host-private ALLOWED and different-internal BLOCKED-with-ownHost (no IP-allowlist leak). KNOWN RESIDUAL (documented in ssrf-guard.js): DNS rebinding — guard resolves, Fedify re-resolves at connect. Fedify-internal getX() fetches (reply-chain, boost) also bypass this path. Both need a connection-time document-loader, tracked as follow-up. --- lib/controllers/profile.remote.js | 16 +++-- lib/lookup-helpers.js | 28 +++++++- lib/og-unfurl.js | 22 +++++- lib/ssrf-guard.js | 115 ++++++++++++++++++++++++++++++ test/ssrf-guard.test.js | 51 +++++++++++++ 5 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 lib/ssrf-guard.js create mode 100644 test/ssrf-guard.test.js diff --git a/lib/controllers/profile.remote.js b/lib/controllers/profile.remote.js index 803ac67..80f58bc 100644 --- a/lib/controllers/profile.remote.js +++ b/lib/controllers/profile.remote.js @@ -5,6 +5,7 @@ import { getToken, validateToken } from "../csrf.js"; import { sanitizeContent } from "../timeline-store.js"; import { lookupWithSecurity } from "../lookup-helpers.js"; +import { assertLookupAllowed } from "../ssrf-guard.js"; /** * GET /admin/reader/profile — Show remote actor profile. @@ -179,11 +180,11 @@ export function followController(mountPath, plugin) { return response.status(400).json({ success: false, error: "Missing actor URL" }); } + // DNS-resolving SSRF guard (replaces a string-prefix check that missed + // 169.254/100.64/IPv6 and was DNS-rebinding bypassable). A remote actor + // to follow is never own-host → blanket private block (no ownHost). try { - const _p = new URL(url); - if (!["http:", "https:"].includes(_p.protocol)) throw new Error("scheme"); - const _h = _p.hostname; - if (_h === "localhost" || _h === "127.0.0.1" || _h === "::1" || /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(_h)) throw new Error("private"); + await assertLookupAllowed(url); } catch { return response.status(400).json({ success: false, error: "Invalid actor URL" }); } @@ -225,6 +226,13 @@ export function unfollowController(mountPath, plugin) { }); } + // SSRF guard — unfollowController previously had zero URL validation. + try { + await assertLookupAllowed(url); + } catch { + return response.status(400).json({ success: false, error: "Invalid actor URL" }); + } + const result = await plugin.unfollowActor(url); return response.json({ diff --git a/lib/lookup-helpers.js b/lib/lookup-helpers.js index 3484e9e..5457004 100644 --- a/lib/lookup-helpers.js +++ b/lib/lookup-helpers.js @@ -6,6 +6,8 @@ * @module lookup-helpers */ +import { assertLookupAllowed } from "./ssrf-guard.js"; + /** * Look up a remote ActivityPub object with cross-origin security. * @@ -21,11 +23,33 @@ * * @param {object} ctx - Fedify Context * @param {string|URL} input - URL or handle to look up - * @param {object} [options] - Additional options passed to lookupObject + * @param {object} [options] - Additional options passed to lookupObject. + * `options.ownHost` (string) is consumed by the SSRF guard and NOT forwarded + * to lookupObject: it names the trusted publication host permitted to resolve + * to a private LAN address (federation of own-site posts). Omit it for sinks + * that must never reach own-host — then ALL private resolved IPs are blocked. * @returns {Promise} Resolved object or null */ export async function lookupWithSecurity(ctx, input, options = {}) { - const baseOptions = { crossOrigin: "ignore", ...options }; + const { ownHost, ...lookupOptions } = options; + + // SSRF guard: federation runs with allowPrivateAddress:true (own host is on + // the LAN), so guard attacker-controlled URLs here. Only http(s) URL inputs + // are guarded; handle/acct: inputs resolve via WebFinger and aren't direct + // fetches of an arbitrary host. Block → return null (existing contract). + const isUrlInput = input instanceof URL || + (typeof input === "string" && /^https?:\/\//i.test(input)); + if (isUrlInput) { + try { + await assertLookupAllowed(input, ownHost); + } catch (error) { + const inputStr = typeof input === "string" ? input : input?.href || String(input); + console.warn(`[ActivityPub] ${error.message} — refusing lookup of ${inputStr}`); + return null; + } + } + + const baseOptions = { crossOrigin: "ignore", ...lookupOptions }; let result = null; try { diff --git a/lib/og-unfurl.js b/lib/og-unfurl.js index 6d514f3..586dd00 100644 --- a/lib/og-unfurl.js +++ b/lib/og-unfurl.js @@ -9,6 +9,21 @@ import { unfurl } from "unfurl.js"; import { extractObjectData } from "./timeline-store.js"; import { lookupWithSecurity } from "./lookup-helpers.js"; +/** + * Trusted publication hostname permitted to resolve to a private LAN address. + * @param {object} collections - MongoDB collections (carries _publicationUrl) + * @returns {string|undefined} hostname or undefined + */ +function ownHostFrom(collections) { + try { + return collections?._publicationUrl + ? new URL(collections._publicationUrl).hostname + : undefined; + } catch { + return undefined; + } +} + const USER_AGENT = "Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)"; const TIMEOUT_MS = 10000; // 10 seconds per URL @@ -282,7 +297,10 @@ export async function fetchAndStorePreviews(collections, uid, html) { */ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, documentLoader) { try { - const object = await lookupWithSecurity(ctx,new URL(quoteUrl), { documentLoader }); + // Own publication host may resolve to its LAN address — a remote can + // legitimately quote one of our own posts by its svemagie.net URL. + const ownHost = ownHostFrom(collections); + const object = await lookupWithSecurity(ctx, new URL(quoteUrl), { documentLoader, ownHost }); if (!object) return; const quoteData = await extractObjectData(object, { documentLoader }); @@ -290,7 +308,7 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume // If author photo is empty, try fetching the actor directly if (!quoteData.author.photo && quoteData.author.url) { try { - const actor = await lookupWithSecurity(ctx,new URL(quoteData.author.url), { documentLoader }); + const actor = await lookupWithSecurity(ctx, new URL(quoteData.author.url), { documentLoader, ownHost }); if (actor) { const { extractActorInfo } = await import("./timeline-store.js"); const actorInfo = await extractActorInfo(actor, { documentLoader }); diff --git a/lib/ssrf-guard.js b/lib/ssrf-guard.js new file mode 100644 index 0000000..d04b531 --- /dev/null +++ b/lib/ssrf-guard.js @@ -0,0 +1,115 @@ +/** + * SSRF guard for federation dereferences. + * @module ssrf-guard + * + * The Fedify federation is created with `allowPrivateAddress: true` because the + * blog host (e.g. svemagie.net) resolves to an RFC-1918 LAN address, and the + * server must dereference its OWN-SITE content during federation. That flag is + * load-bearing and stays. The cost is that Fedify's blanket SSRF guard is off + * for every fetch, so attacker-sourced URLs (quoteUrl, reply targets, boost + * objects, resolved author URLs) could otherwise reach internal services. + * + * This guard re-establishes protection per-sink: it resolves the target host + * via DNS and rejects any host whose resolved IP is private/reserved — UNLESS + * the hostname STRING-EQUALS the trusted publication host. Allowlisting by + * hostname (not by resolved IP) is deliberate: the publication's private IP is + * shared by every other LAN service, so an IP allowlist would let any attacker + * hostname resolving into that subnet through. + * + * KNOWN RESIDUAL RISK — DNS rebinding: this guard resolves DNS, then Fedify + * performs its OWN resolution at connect time. A TTL=0 attacker can return a + * public IP to this check and an internal IP to Fedify's fetch. Closing that + * requires a connection-time check inside Fedify's document loader (tracked as + * a follow-up). This guard DOES block literal-internal URLs, naive attacks, and + * the previously-unguarded quote/reply/boost/author paths. + */ + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +/** + * Check whether an IP literal is in a private/reserved range. + * @param {string} ip - IPv4 or IPv6 literal + * @returns {boolean} True if private/reserved + */ +export function isPrivateIP(ip) { + const family = isIP(ip); + if (family === 0) return true; // not a valid IP → block defensively + + if (family === 4) { + const [a, b] = ip.split(".").map(Number); + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 0) return true; // 0.0.0.0/8 + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a >= 224) return true; // multicast + reserved + return false; + } + + // IPv6 + const lower = ip.toLowerCase(); + if (lower === "::1" || lower === "::") return true; + const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateIP(mapped[1]); // IPv4-mapped + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA fc00::/7 + if (lower.startsWith("fe8") || lower.startsWith("fe9") || + lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10 + return false; +} + +/** + * Assert that a URL is allowed to be dereferenced during federation. + * + * Resolves DNS and throws if the host's resolved IP is private/reserved, + * unless the hostname string-equals `ownHost`. Non-HTTP(S) schemes are + * rejected. DNS resolution failure fails closed (throws). + * + * @param {string|URL} input - URL to validate + * @param {string} [ownHost] - Trusted publication hostname permitted to resolve + * to a private address (e.g. "svemagie.net"). When omitted, ALL private + * resolved IPs are blocked (fail-closed default). + * @returns {Promise} Resolves if allowed; rejects otherwise + */ +export async function assertLookupAllowed(input, ownHost) { + let parsed; + try { + parsed = input instanceof URL ? input : new URL(input); + } catch { + throw new Error("SSRF guard: invalid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`SSRF guard: blocked non-HTTP scheme ${parsed.protocol}`); + } + + const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + + // Trusted publication host is allowed even when it resolves to a private IP. + // String equality, NOT resolved-IP equality (shared-LAN-subnet bypass). + if (ownHost && host === ownHost.toLowerCase()) return; + + // Host is already an IP literal — check directly, no DNS. + if (isIP(host) !== 0) { + if (isPrivateIP(host)) throw new Error(`SSRF guard: blocked private address ${host}`); + return; + } + + // Resolve ALL addresses; block if ANY is private (rebinding-resistant for the + // resolve step — see module note re: connect-time residual). + let addresses; + try { + addresses = await lookup(host, { all: true }); + } catch { + throw new Error(`SSRF guard: DNS resolution failed for ${host}`); + } + if (addresses.length === 0) throw new Error(`SSRF guard: no addresses for ${host}`); + + for (const { address } of addresses) { + if (isPrivateIP(address)) { + throw new Error(`SSRF guard: ${host} resolves to private address ${address}`); + } + } +} diff --git a/test/ssrf-guard.test.js b/test/ssrf-guard.test.js new file mode 100644 index 0000000..1ef89e3 --- /dev/null +++ b/test/ssrf-guard.test.js @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { assertLookupAllowed, isPrivateIP } from "../lib/ssrf-guard.js"; + +test("isPrivateIP blocks internal IPv4", () => { + for (const ip of ["10.100.0.5", "127.0.0.1", "169.254.169.254", + "172.16.0.1", "172.31.255.255", "192.168.1.1", "0.0.0.0", "100.64.0.1"]) { + 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"]) { + assert.equal(isPrivateIP(ip), false, `${ip} should be public`); + } +}); + +test("isPrivateIP blocks internal IPv6 + IPv4-mapped", () => { + for (const ip of ["::1", "fc00::1", "fd12::1", "fe80::1", "::ffff:10.0.0.1"]) { + assert.equal(isPrivateIP(ip), true, `${ip} should be private`); + } +}); + +test("assertLookupAllowed blocks attacker host resolving to internal IP literal", async () => { + await assert.rejects(() => assertLookupAllowed("http://10.100.0.5/inbox"), /private address/); + await assert.rejects(() => assertLookupAllowed("http://127.0.0.1/"), /private address/); + await assert.rejects(() => assertLookupAllowed("http://[::1]/"), /private address/); + await assert.rejects(() => assertLookupAllowed("http://169.254.169.254/latest/"), /private address/); +}); + +test("assertLookupAllowed blocks non-HTTP schemes", async () => { + await assert.rejects(() => assertLookupAllowed("file:///etc/passwd"), /non-HTTP/); +}); + +test("assertLookupAllowed PERMITS own-host even when it resolves to a private IP", async () => { + // localhost resolves to 127.0.0.1 (private). With ownHost=localhost the guard + // must allow it — this is the own-site-federation exception that keeps + // svemagie.net (on the LAN) reachable. + await assert.doesNotReject(() => assertLookupAllowed("http://localhost/users/sven", "localhost")); +}); + +test("assertLookupAllowed BLOCKS a different host even with ownHost set (no IP-allowlist leak)", async () => { + // ownHost is set to localhost, but the target is a DIFFERENT internal literal. + // Must still block — proves allowlist is by hostname, not by resolved IP. + await assert.rejects(() => assertLookupAllowed("http://10.100.0.5/", "localhost"), /private address/); +}); + +test("assertLookupAllowed permits a public host", async () => { + await assert.doesNotReject(() => assertLookupAllowed("https://example.com/users/alice")); +});