Skip to content

fix(api): close SSRF bypass via IPv6 transition addresses in webhooks - #449

Merged
driaug merged 1 commit into
nextfrom
fix/ssrf-ipv6-transition-addresses
Aug 9, 2026
Merged

fix(api): close SSRF bypass via IPv6 transition addresses in webhooks#449
driaug merged 1 commit into
nextfrom
fix/ssrf-ipv6-transition-addresses

Conversation

@driaug

@driaug driaug commented Aug 9, 2026

Copy link
Copy Markdown
Member

The webhook SSRF guard in WorkflowExecutionService validated IPv6 by string prefix, which only covered ::1, fe80:, fc, fd and ff. Any address form that encodes an IPv4 target inside IPv6 slipped through, letting an authenticated user with workflow permissions reach internal services — including cloud metadata at 169.254.169.254:

2002:a9fe:a9fe:: 6to4 (RFC 3056)
64:ff9b::a9fe:a9fe NAT64 (RFC 6052)
2001:0000:4136:e378:... Teredo (RFC 4380)
0:0:0:0:0:ffff:169.254.169.254 uncompressed IPv4-mapped
::169.254.169.254 IPv4-compatible (deprecated)

The last two are not in the report but are the same class of hole: prefix matching does not survive alternate spellings of the same address, so adding more startsWith() checks would not have closed them. isPrivateIp now expands IPv6 into its 8 hextets and range-checks numerically, unwrapping the embedded IPv4 of each transition mechanism and re-validating it against the IPv4 rules. Teredo is blocked outright since its endpoints are obfuscated.

This also fixes two prefix checks that were wrong on their own terms: fe80: missed fe81::-febf:: within fe80::/10, and :: (unspecified) was allowed.

Separately, safeFetch passed URL.hostname straight to dns.lookup, but the WHATWG parser keeps the brackets on IPv6 literals, so http://[::1]/ threw in the resolver instead of being validated. It failed closed, but IP literals were never actually checked. Brackets are now stripped and literal IPs validated directly without a DNS round-trip.

The webhook SSRF guard in WorkflowExecutionService validated IPv6 by
string prefix, which only covered ::1, fe80:, fc, fd and ff. Any address
form that encodes an IPv4 target inside IPv6 slipped through, letting an
authenticated user with workflow permissions reach internal services —
including cloud metadata at 169.254.169.254:

  2002:a9fe:a9fe::                6to4    (RFC 3056)
  64:ff9b::a9fe:a9fe              NAT64   (RFC 6052)
  2001:0000:4136:e378:...         Teredo  (RFC 4380)
  0:0:0:0:0:ffff:169.254.169.254  uncompressed IPv4-mapped
  ::169.254.169.254               IPv4-compatible (deprecated)

The last two are not in the report but are the same class of hole: prefix
matching does not survive alternate spellings of the same address, so
adding more startsWith() checks would not have closed them. isPrivateIp
now expands IPv6 into its 8 hextets and range-checks numerically,
unwrapping the embedded IPv4 of each transition mechanism and
re-validating it against the IPv4 rules. Teredo is blocked outright since
its endpoints are obfuscated.

This also fixes two prefix checks that were wrong on their own terms:
fe80: missed fe81::-febf:: within fe80::/10, and :: (unspecified) was
allowed.

Separately, safeFetch passed URL.hostname straight to dns.lookup, but the
WHATWG parser keeps the brackets on IPv6 literals, so http://[::1]/ threw
in the resolver instead of being validated. It failed closed, but IP
literals were never actually checked. Brackets are now stripped and
literal IPs validated directly without a DNS round-trip.

Known gap, not addressed here: safeFetch validates a resolved address and
then calls fetch() on the hostname, which resolves independently. DNS
rebinding with a short TTL still bypasses the guard. Closing that needs a
pinned-IP fetch.

Reported by tonghuaroot (tonghuaroot@gmail.com).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@driaug
driaug merged commit a60ce6b into next Aug 9, 2026
5 checks passed
@driaug
driaug deleted the fix/ssrf-ipv6-transition-addresses branch August 9, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant