6a1d55b836
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.
52 lines
2.4 KiB
JavaScript
52 lines
2.4 KiB
JavaScript
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"));
|
|
});
|