fix(api): close SSRF bypass via IPv6 transition addresses in webhooks - #449
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.