VYPR
High severity8.6NVD Advisory· Published Jun 10, 2026

CVE-2026-50131

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
  • Fedify Dev/Fedifyinferred2 versions
    >=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

3
c5b46f82668e

Harden public URL address validation

https://github.com/fedify-dev/fedifyHong MinheeMay 29, 2026Fixed in 2.1.15via llm-release-walk
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;
    +}
    
46b20577267c

Changelog for CVE-2026-50131

https://github.com/fedify-dev/fedifyHong MinheeJun 2, 2026via github-commit-search
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
     --------------
    
6f32ca07b123

Use routable IP fixtures in image tests

https://github.com/fedify-dev/fedifyHong MinheeJun 4, 2026Fixed in 2.1.15via llm-release-walk
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

1

News mentions

0

No linked articles in our index yet.