security: SSRF guard on federation dereferences (own-host allowlist)

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.
This commit is contained in:
svemagie
2026-06-14 10:58:24 +02:00
parent f2defa37bb
commit 6a1d55b836
5 changed files with 224 additions and 8 deletions
+12 -4
View File
@@ -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({
+26 -2
View File
@@ -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<object|null>} 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 {
+20 -2
View File
@@ -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 });
+115
View File
@@ -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<void>} 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}`);
}
}
}
+51
View File
@@ -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"));
});