Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / TransitBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
GHSA-c5xr-mg26-vq5w

TransitBleed

2026-08-03 tonghuaroot and xet7

Coordinated disclosure via GitHub Security Advisory GHSA-c5xr-mg26-vq5w.
  • TransitBleed — the SSRF block-list read an IPv6 address by its spelling, so 6to4, NAT64 and Teredo addresses carried an internal IPv4 destination straight through it (CWE-918 Server-Side Request Forgery)
  • Affected isIpBlocked() in models/lib/attachmentUrlValidation.js, the one block-list behind both the input-time URL validator and the delivery-time guard server/lib/ssrfGuard.js
  • Fixed at upcoming WeKan release


Details

TransitBleed — IPv6 transition addresses walked through the SSRF block-list (CWE-918)

Wherever WeKan fetches a URL somebody else chose — an attachment URL, an outgoing webhook, an avatar, every import path — the address it is about to contact is checked against one block-list, isIpBlocked() in models/lib/attachmentUrlValidation.js. It is deliberately the only one: the input-time validator and the delivery-time guard fetchSafe() both call it, so the two cannot drift apart. That was itself the fix for DnsBleed.

Its IPv6 half decided from the spelling of the address:

// vulnerable — string prefixes decide
if (normalized.startsWith('::ffff:')) {
  return isIpv4Blocked(normalized.replace('::ffff:', ''));
}
if (normalized.startsWith('2001:db8')) return true;
const firstValue = parseInt(normalized.split(':')[0], 16);
if (firstValue >= 0xfc00 && firstValue <= 0xfdff) return true;   // fc00::/7
if (firstValue >= 0xfe80 && firstValue <= 0xfebf) return true;   // fe80::/10
if (firstValue >= 0xff00) return true;                            // ff00::/8
return false;
    

IPv6 has several standard ways to write “this packet goes to an IPv4 address”, and none of them looks like ::ffff::

Transition mechanism Prefix Where the IPv4 sits Example wrapping 169.254.169.254
6to4 (RFC 3056) 2002::/16 bits 16–48 2002:a9fe:a9fe::
NAT64 well-known prefix (RFC 6052) 64:ff9b::/96 low 32 bits 64:ff9b::a9fe:a9fe
Teredo (RFC 4380) 2001:0000::/32 low 32 bits, as the bitwise complement 2001:0:5ef5:79fd:0:0:5601:5601

On infrastructure with a 6to4 relay or a NAT64 gateway — common in cloud and Kubernetes networks — the packet really does arrive at the embedded IPv4 address. So http://[2002:a9fe:a9fe::]/latest/meta-data/ read the cloud metadata endpoint through the guard whose whole job was to stop it, and http://[2002:7f00:0001::]/, http://[2002:0a00:0001::]/ and http://[64:ff9b::c0a8:101]/ reached 127.0.0.1, 10.0.0.1 and 192.168.1.1. Because both halves of the defence share this one function, both the URL a user submits and the request WeKan actually sends were affected.

Deciding from the spelling failed in a plainer way too: 0:0:0:0:0:ffff:7f00:1 is an ordinary IPv4-mapped 127.0.0.1, merely written out instead of compressed, and startsWith('::ffff:') does not match it.

Fix: expand the address to its 16 bytes once, and read those bytes. Notation then cannot change the answer — compressed or not, dotted-quad tail or not, with an interface zone or without. Every form that embeds an IPv4 address has it extracted and re-checked with the IPv4 rules, so if the embedded IPv4 is blocked, its IPv6 spelling is blocked too.

// fixed — models/lib/attachmentUrlValidation.js
function isIpv6Blocked(ip) {
  const bytes = ipv6ToBytes(ip);
  if (!bytes) return true;                      // unparseable → fail closed

  if (embeddedIpv4Addresses(bytes).some(address => isIpv4Blocked(address))) return true;

  if (bytes[0] === 0x20 && bytes[1] === 0x01
      && bytes[2] === 0x0d && bytes[3] === 0xb8) return true;   // 2001:db8::/32
  if (bytes[0] === 0xfc || bytes[0] === 0xfd) return true;         // fc00::/7
  if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return true; // fe80::/10
  if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0xc0) return true; // fec0::/10
  if (bytes[0] === 0xff) return true;                              // ff00::/8
  return false;
}
    

The forms extracted are 6to4, NAT64 (the well-known prefix and the RFC 8215 local-use one, the latter at every RFC 6052 embedding position because only the gateway knows which it uses), Teredo through both its server address and its obfuscated client address, IPv4-mapped, IPv4-translated, IPv4-compatible, and ISATAP under any routing prefix rather than only the link-local one the reporter's list stops at. The deprecated fec0::/10 site-local range is blocked as well. Anything that is not a valid IPv6 address is blocked, as before.

A transition address wrapping a public IPv4 is still allowed — 2002:5db8:d822:: and 64:ff9b::93.184.216.34 both go through — and tests/transitbleed.test.cjs pins that as carefully as it pins the bypasses, because a guard that blocks everything is a guard somebody switches off.

Reported privately by tonghuaroot via GitHub Security Advisory GHSA-c5xr-mg26-vq5w. Fixed at the upcoming WeKan release.