CVE-2026-50131
Description
Fedify library's SSRF defense is bypassed by incomplete IPv4 validation, allowing access to special-use network ranges.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Fedify library's SSRF defense is bypassed by incomplete IPv4 validation, allowing access to special-use network ranges.
Vulnerability
A vulnerability exists in the Fedify TypeScript library, specifically in the validatePublicUrl() function within packages/vocab-runtime/src/url.ts. Versions from 0.11.2 up to, but not including, 1.9.12, 1.10.11, 2.0.19, 2.1.15, and 2.2.4 are affected. The isValidPublicIPv4Address() function, intended to prevent Server-Side Request Forgery (SSRF) by rejecting non-public IPv4 addresses, incorrectly permits several special-use, reserved, multicast, benchmarking, and carrier-grade NAT IPv4 ranges, failing to block access to these destinations [1].
Exploitation
An attacker can exploit this vulnerability by crafting a URL that resolves to one of the incompletely validated special-use IPv4 ranges. Since the validatePublicUrl() function is called before outbound fetches for documents and media, an attacker can trick the Fedify application into making requests to internal or reserved network destinations that should have been blocked by the SSRF mitigation [1].
Impact
Successful exploitation allows an attacker to bypass the SSRF protections and potentially access internal network resources or trigger unintended network interactions. This could lead to information disclosure, unauthorized access to sensitive internal services, or other impacts depending on the specific internal network configuration and the attacker's ability to interact with the targeted special-use IP addresses [1].
Mitigation
Versions 1.9.12, 1.10.11, 2.0.19, 2.1.15, and 2.2.4 contain an updated patch that addresses this incomplete validation. Users are advised to upgrade to these patched versions or later. No workarounds are mentioned in the available references [1].
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2>=2.1.15,<2.2.4+ 1 more
- (no CPE)range: >=2.1.15,<2.2.4
- (no CPE)range: >=0.11.2, <1.9.12, <1.10.11, <2.0.19, <2.1.15, <2.2.4
Patches
3c5b46f82668eHarden public URL address validation
2 files changed · +258 −16
packages/fedify/src/runtime/url.test.ts+76 −0 modified@@ -26,7 +26,46 @@ test("validatePublicUrl()", async () => { () => validatePublicUrl("http://[::ffff:7f00:1]/"), UrlError, ); + await assertRejects( + () => validatePublicUrl("https://[64:ff9b::7f00:1]/"), + UrlError, + ); + await assertRejects( + () => validatePublicUrl("https://[64:ff9b::a00:1]/"), + UrlError, + ); + await assertRejects( + () => validatePublicUrl("https://[64:ff9b:1::a00:1]/"), + UrlError, + ); + await assertRejects( + () => validatePublicUrl("https://[64:ff9b:1::808:808]/"), + UrlError, + ); + await assertRejects( + () => validatePublicUrl("https://[2001::]/"), + UrlError, + ); + await assertRejects( + () => validatePublicUrl("https://[2002:a00:1::]/"), + UrlError, + ); + for ( + const url of [ + "https://100.64.0.1", + "https://198.18.0.1", + "https://224.0.0.1", + "https://240.0.0.1", + "https://192.0.2.1", + "https://192.88.99.1", + "https://198.51.100.1", + "https://203.0.113.1", + ] + ) { + await assertRejects(() => validatePublicUrl(url), UrlError); + } await validatePublicUrl("https://[2001:db8::1]"); + await validatePublicUrl("https://[64:ff9b::8.8.8.8]"); }); test("isValidPublicIPv4Address()", () => { @@ -36,6 +75,24 @@ test("isValidPublicIPv4Address()", () => { assertFalse(isValidPublicIPv4Address("10.0.0.1")); // private assertFalse(isValidPublicIPv4Address("127.16.0.1")); // private assertFalse(isValidPublicIPv4Address("169.254.0.1")); // link-local + assertFalse(isValidPublicIPv4Address("100.64.0.1")); // shared address space + assertFalse(isValidPublicIPv4Address("100.127.255.255")); + assertFalse(isValidPublicIPv4Address("192.0.0.1")); // IETF protocol + assertFalse(isValidPublicIPv4Address("192.0.2.1")); // documentation + assertFalse(isValidPublicIPv4Address("192.88.99.0")); // 6to4 relay anycast + assertFalse(isValidPublicIPv4Address("192.88.99.1")); + assertFalse(isValidPublicIPv4Address("192.88.99.2")); // 6a44 relay anycast + assertFalse(isValidPublicIPv4Address("192.88.99.255")); + assertFalse(isValidPublicIPv4Address("198.18.0.1")); // benchmarking + assertFalse(isValidPublicIPv4Address("198.19.255.255")); + assertFalse(isValidPublicIPv4Address("198.51.100.1")); // documentation + assertFalse(isValidPublicIPv4Address("203.0.113.1")); // documentation + assertFalse(isValidPublicIPv4Address("224.0.0.1")); // multicast + assertFalse(isValidPublicIPv4Address("239.255.255.255")); + assertFalse(isValidPublicIPv4Address("240.0.0.1")); // reserved + assertFalse(isValidPublicIPv4Address("255.255.255.255")); // broadcast + assertFalse(isValidPublicIPv4Address("1.2.3")); + assertFalse(isValidPublicIPv4Address("999.1.1.1")); }); test("isValidPublicIPv6Address()", () => { @@ -46,6 +103,21 @@ test("isValidPublicIPv6Address()", () => { assertFalse(isValidPublicIPv6Address("ff00::1")); // multicast assertFalse(isValidPublicIPv6Address("::")); // unspecified assertFalse(isValidPublicIPv6Address("::ffff:7f00:1")); // IPv4-mapped + assertFalse(isValidPublicIPv6Address("64:ff9b::7f00:1")); // NAT64 localhost + assertFalse(isValidPublicIPv6Address("64:ff9b::127.0.0.1")); + assertFalse(isValidPublicIPv6Address("64:ff9b::a00:1")); // NAT64 private + assertFalse(isValidPublicIPv6Address("64:ff9b::10.0.0.1")); + assertFalse(isValidPublicIPv6Address("64:ff9b:1::")); // local-use NAT64 + assertFalse(isValidPublicIPv6Address("64:ff9b:1::a00:1")); + assertFalse(isValidPublicIPv6Address("64:ff9b:1::10.0.0.1")); + assertFalse(isValidPublicIPv6Address("2001::")); // Teredo + assertFalse(isValidPublicIPv6Address("2001:0:4136:e378:8000:63bf:3fff:fdd2")); + assertFalse(isValidPublicIPv6Address("2002:a00:1::")); // 6to4 + assertFalse(isValidPublicIPv6Address("2002:7f00:1::")); + assertFalse(isValidPublicIPv6Address("2002:c0a8:1::")); + assertFalse(isValidPublicIPv6Address("2002:a9fe:1::")); + assert(isValidPublicIPv6Address("64:ff9b::808:808")); // NAT64 public + assert(isValidPublicIPv6Address("64:ff9b::8.8.8.8")); }); test("expandIPv6Address()", () => { @@ -65,4 +137,8 @@ test("expandIPv6Address()", () => { expandIPv6Address("2001:db8::1"), "2001:0db8:0000:0000:0000:0000:0000:0001", ); + assertEquals( + expandIPv6Address("64:ff9b::8.8.8.8"), + "0064:ff9b:0000:0000:0000:0000:0808:0808", + ); });
packages/fedify/src/runtime/url.ts+182 −16 modified@@ -70,29 +70,45 @@ function validatePublicIpAddress(address: string, family: number): void { } export function isValidPublicIPv4Address(address: string): boolean { - const parts = address.split("."); - const first = parseInt(parts[0]); - if (first === 0 || first === 10 || first === 127) return false; - const second = parseInt(parts[1]); - if (first === 169 && second === 254) return false; - if (first === 172 && second >= 16 && second <= 31) return false; - if (first === 192 && second === 168) return false; - return true; + const parts = parseIPv4Address(address); + if (parts == null) return false; + const value = ipv4PartsToNumber(parts); + return !nonPublicIPv4Prefixes.some(({ base, prefix }) => + matchesIPv4Prefix(value, base, prefix) + ); } export function isValidPublicIPv6Address(address: string) { - address = expandIPv6Address(address); - if (address.at(4) !== ":") return false; - const firstWord = parseInt(address.substring(0, 4), 16); - return !( - (firstWord >= 0xfc00 && firstWord <= 0xfdff) || // ULA - (firstWord >= 0xfe80 && firstWord <= 0xfebf) || // Link-local - firstWord === 0 || firstWord >= 0xff00 // Multicast - ); + const words = parseIPv6Address(address); + if (words == null) return false; + if ( + nonPublicIPv6Prefixes.some(({ words: prefixWords, prefix }) => + matchesIPv6Prefix(words, prefixWords, prefix) + ) + ) return false; + for ( + const { extractIPv4, prefix, words: prefixWords } of ipv6WithIPv4Prefixes + ) { + if (!matchesIPv6Prefix(words, prefixWords, prefix)) continue; + const ipv4Address = extractIPv4(words); + if (ipv4Address != null && !isValidPublicIPv4Address(ipv4Address)) { + return false; + } + } + return true; } export function expandIPv6Address(address: string): string { address = address.toLowerCase(); + const ipv4Delimiter = address.lastIndexOf(":"); + if (address.includes(".") && ipv4Delimiter >= 0) { + const ipv4Parts = parseIPv4Address(address.substring(ipv4Delimiter + 1)); + if (ipv4Parts == null) return address; + const high = (ipv4Parts[0] << 8) + ipv4Parts[1]; + const low = (ipv4Parts[2] << 8) + ipv4Parts[3]; + address = address.substring(0, ipv4Delimiter + 1) + + high.toString(16) + ":" + low.toString(16); + } if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000"; if (address.startsWith("::")) address = "0000" + address; if (address.endsWith("::")) address = address + "0000"; @@ -103,3 +119,153 @@ export function expandIPv6Address(address: string): string { const parts = address.split(":"); return parts.map((part) => part.padStart(4, "0")).join(":"); } + +type IPv4Prefix = { + cidr: string; + base: number; + prefix: number; + rfc: string; +}; + +// Keep CIDR and RFC metadata in the table instead of row comments so security +// reviewers can audit each blocked range without duplicating source text. +const nonPublicIPv4Prefixes = [ + ipv4Prefix("0.0.0.0/8", "RFC 6890"), + ipv4Prefix("10.0.0.0/8", "RFC 1918"), + ipv4Prefix("100.64.0.0/10", "RFC 6598"), + ipv4Prefix("127.0.0.0/8", "RFC 1122"), + ipv4Prefix("169.254.0.0/16", "RFC 3927"), + ipv4Prefix("172.16.0.0/12", "RFC 1918"), + ipv4Prefix("192.0.0.0/24", "RFC 6890"), + ipv4Prefix("192.0.2.0/24", "RFC 5737"), + ipv4Prefix("192.88.99.0/24", "RFC 7526"), + ipv4Prefix("192.168.0.0/16", "RFC 1918"), + ipv4Prefix("198.18.0.0/15", "RFC 2544"), + ipv4Prefix("198.51.100.0/24", "RFC 5737"), + ipv4Prefix("203.0.113.0/24", "RFC 5737"), + ipv4Prefix("224.0.0.0/4", "RFC 5771"), + ipv4Prefix("240.0.0.0/4", "RFC 1112"), +]; + +type IPv6Prefix = { + cidr: string; + words: number[]; + prefix: number; + rfc: string; +}; + +const nonPublicIPv6Prefixes = [ + ipv6Prefix("::/16", "RFC 4291"), + ipv6Prefix("2001::/32", "RFC 4380"), + ipv6Prefix("2002::/16", "RFC 3056"), + ipv6Prefix("64:ff9b:1::/48", "RFC 8215"), + ipv6Prefix("fc00::/7", "RFC 4193"), + ipv6Prefix("fe80::/10", "RFC 4291"), + ipv6Prefix("ff00::/8", "RFC 4291"), +]; + +type IPv6WithIPv4Prefix = IPv6Prefix & { + extractIPv4: (words: number[]) => string | null; +}; + +// This table has one entry for now, but keeps embedded IPv4 extraction aligned +// with the CIDR metadata above if another translation prefix needs it later. +const ipv6WithIPv4Prefixes: IPv6WithIPv4Prefix[] = [ + { + ...ipv6Prefix("64:ff9b::/96", "RFC 6052"), + extractIPv4: (words) => ipv4FromWords(words[6], words[7]), + }, +]; + +function ipv4Prefix(cidr: string, rfc: string): IPv4Prefix { + const [address, prefixText] = cidr.split("/"); + const prefix = parseInt(prefixText, 10); + const parts = parseIPv4Address(address); + if (parts == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + throw new Error(`Invalid IPv4 prefix: ${cidr}`); + } + return { cidr, base: ipv4PartsToNumber(parts), prefix, rfc }; +} + +function ipv6Prefix(cidr: string, rfc: string): IPv6Prefix { + const [address, prefixText] = cidr.split("/"); + const prefix = parseInt(prefixText, 10); + const words = parseIPv6Address(address); + if ( + words == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128 + ) { + throw new Error(`Invalid IPv6 prefix: ${cidr}`); + } + return { cidr, words, prefix, rfc }; +} + +function parseIPv4Address(address: string): number[] | null { + const parts = address.split(".").map((part) => { + if (!/^\d+$/.test(part)) return NaN; + return parseInt(part, 10); + }); + // Keep explicit bounds checks even though the regex narrows today's parser; + // they make future parser changes fail closed. + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) return null; + return parts; +} + +function parseIPv6Address(address: string): number[] | null { + const parts = expandIPv6Address(address).split(":"); + if (parts.length !== 8) return null; + const words = parts.map((part) => { + if (!/^[0-9a-f]{1,4}$/i.test(part)) return NaN; + return parseInt(part, 16); + }); + // Keep explicit bounds checks even though the regex narrows today's parser; + // they make future parser changes fail closed. + if ( + words.some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff) + ) return null; + return words; +} + +function ipv4PartsToNumber(parts: number[]): number { + return parts[0] * 2 ** 24 + parts[1] * 2 ** 16 + parts[2] * 2 ** 8 + + parts[3]; +} + +function ipv4FromWords(highWord: number, lowWord: number): string { + return [ + highWord >> 8, + highWord & 0xff, + lowWord >> 8, + lowWord & 0xff, + ].join("."); +} + +function matchesIPv4Prefix( + address: number, + prefixBase: number, + prefixLength: number, +): boolean { + const blockSize = 2 ** (32 - prefixLength); + return Math.floor(address / blockSize) === Math.floor(prefixBase / blockSize); +} + +function matchesIPv6Prefix( + address: number[], + prefixWords: number[], + prefixLength: number, +): boolean { + let remaining = prefixLength; + for (let i = 0; i < 8 && remaining > 0; i++) { + if (remaining >= 16) { + if (address[i] !== prefixWords[i]) return false; + remaining -= 16; + } else { + const mask = (0xffff << (16 - remaining)) & 0xffff; + if ((address[i] & mask) !== (prefixWords[i] & mask)) return false; + remaining = 0; + } + } + return true; +}
46b20577267cChangelog for CVE-2026-50131
1 file changed · +13 −0
CHANGES.md+13 −0 modified@@ -8,6 +8,19 @@ Version 1.9.12 To be released. +### @fedify/fedify + + - Fixed `validatePublicUrl()` allowing special-use IPv4 ranges, such as + shared address space, benchmarking, multicast, reserved, and documentation + ranges, which could bypass private network protections in remote document + loading. [[CVE-2026-50131]] + + - Fixed `validatePublicUrl()` allowing IPv6 translation and tunneling + prefixes, including NAT64, Teredo, and 6to4 addresses, which could bypass + private network protections in remote document loading. [[CVE-2026-50131]] + +[CVE-2026-50131]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-xw9q-2mv6-9fr8 + Version 1.9.11 --------------
6f32ca07b123Use routable IP fixtures in image tests
1 file changed · +5 −5
packages/cli/src/imagerenderer.test.ts+5 −5 modified@@ -4,8 +4,8 @@ import path from "node:path"; import test from "node:test"; import { downloadImage } from "./imagerenderer.ts"; -const TEST_PUBLIC_IMAGE_URL = "https://198.51.100.10/image.png"; -const TEST_PUBLIC_REDIRECT_URL = "https://198.51.100.11/final.png"; +const TEST_PUBLIC_IMAGE_URL = "https://8.8.8.8/image.png"; +const TEST_PUBLIC_REDIRECT_URL = "https://1.1.1.1/final.png"; test("downloadImage - skips private URL without fetching", async () => { let called = false; @@ -182,7 +182,7 @@ test("downloadImage - rejects unsafe extension containing path traversal", async try { const result = await downloadImage( - "https://198.51.100.10/image.png/..%2f..%2f..%2fetc%2fpasswd", + "https://8.8.8.8/image.png/..%2f..%2f..%2fetc%2fpasswd", ); assert.equal(result, null); } finally { @@ -198,7 +198,7 @@ test("downloadImage - falls back to jpg when URL has no extension", async () => let result: string | null = null; try { - result = await downloadImage("https://198.51.100.10/image"); + result = await downloadImage("https://8.8.8.8/image"); assert.notEqual(result, null); assert.equal(path.extname(result!), ".jpg"); } finally { @@ -220,7 +220,7 @@ test("downloadImage - falls back to content type for extensionless nested path", let result: string | null = null; try { - result = await downloadImage("https://198.51.100.10/media/12345"); + result = await downloadImage("https://8.8.8.8/media/12345"); assert.notEqual(result, null); assert.equal(path.extname(result!), ".png"); } finally {
Vulnerability mechanics
Root cause
"The IPv4 validation logic in `isValidPublicIPv4Address` incorrectly classifies several special-use, reserved, multicast, benchmarking, and carrier-grade NAT IPv4 ranges as public."
Attack vector
An attacker can craft an ActivityPub object, activity, document, or media URL that resolves to a special-use IPv4 address. When the Fedify server processes this URL and relies on the incomplete `validatePublicUrl()` function for SSRF protection, it will incorrectly deem the destination as public. This allows the server to initiate outbound requests to these non-public or special-use network ranges, bypassing the intended security boundary [ref_id=1].
Affected code
The vulnerability lies within the `isValidPublicIPv4Address` function located in `packages/vocab-runtime/src/url.ts`. This function is responsible for determining if an IPv4 address is considered public before outbound fetches are made. The current implementation incorrectly returns true for several special-use IPv4 ranges that should not be treated as public destinations [ref_id=1].
What the fix does
The advisory suggests avoiding a manual denylist for IP validation and instead ensuring the resolved address is globally routable. At a minimum, all relevant special-use IPv4 ranges should be rejected. A safer long-term fix involves using a maintained IP address classification library for security-sensitive validation. The provided patch idea implements a more comprehensive check against various special-use ranges [ref_id=1].
Preconditions
- inputAn attacker must be able to provide a URL to the Fedify server that resolves to a special-use IPv4 address.
- configThe Fedify server must be configured to process remote ActivityPub objects, activities, documents, or media URLs.
Reproduction
The researcher provided a proof-of-concept script demonstrating the incorrect IPv4 validation behavior, showing that special-use ranges like `100.64.0.1` and `198.18.0.1` are incorrectly classified as public by the `isValidPublicIPv4Address` function [ref_id=1].
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.