C#: Add experimental SSRF IPv6-transition incomplete-guard query (CWE-918/CWE-1389)#148
Conversation
Adds githubsecuritylab/cs/ssrf-ipv6-transition-incomplete-guard, which flags SSRF host-validation guards that reject private/loopback IPv4 ranges but never unwrap IPv6-transition forms (IPv4-mapped ::ffff:, NAT64 64:ff9b::, 6to4 2002::). Such guards can be bypassed by wrapping an internal IPv4 address in a transition literal, so the validator classifies the host as public while the OS still routes to the internal endpoint (CWE-918 / CWE-1389). Includes qhelp, good/bad examples, and a unit test.
There was a problem hiding this comment.
Pull request overview
Adds an experimental C# CodeQL query to detect SSRF host-validation guards that attempt to block internal IPv4 addresses but fail to unwrap IPv6-transition representations (IPv4-mapped IPv6, NAT64, 6to4), plus accompanying qhelp, examples, and a unit test.
Changes:
- Introduces
githubsecuritylab/cs/ssrf-ipv6-transition-incomplete-guardquery undercsharp/src/security/CWE-918/. - Adds qhelp documentation and Good/Bad examples illustrating the vulnerability and mitigations.
- Adds a new unit test suite (
.cs/.qlref/.expected/options) for the query.
Show a summary per file
| File | Description |
|---|---|
| csharp/src/security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql | New experimental query detecting IPv6-transition unwrap gaps in SSRF host guards. |
| csharp/src/security/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp | Query help text describing risk, recommendation, and examples. |
| csharp/src/security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.cs | “Bad” example demonstrating a bypassable host guard. |
| csharp/src/security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.cs | “Good” example demonstrating transition-aware normalization before checks. |
| csharp/test/security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.cs | Test code with in-scope and suppressed guard patterns. |
| csharp/test/security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref | Test reference to the new query. |
| csharp/test/security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected | Expected results for the new query test. |
| csharp/test/security/CWE-918/SsrfIpv6TransitionIncompleteGuard/options | Extractor options for compiling the test with stubs. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 8/8 changed files
- Comments generated: 4
| predicate hasRfc1918Literal(Callable c) { | ||
| exists(StringLiteral s | s.getEnclosingCallable() = c | | ||
| s.getValue() | ||
| .regexpMatch("(?i).*(127\\.0\\.0\\.1|169\\.254\\.169\\.254|10\\.|192\\.168|172\\.1[6-9]|::1|fc00|fd00|metadata\\.google).*") |
| * Holds if `c` contains a hand-written RFC 1918, loopback or cloud-metadata IPv4 literal | ||
| * used as a denylist entry. |
| { | ||
| byte[] b = addr.GetAddressBytes(); | ||
| // NAT64 well-known prefix 64:ff9b::/96 -> last 4 bytes are the embedded IPv4. | ||
| if (b.Length == 16 && b[0] == 0x00 && b[1] == 0x64 && b[2] == 0xff && b[3] == 0x9b) |
| byte[] b = addr.GetAddressBytes(); | ||
| return b.Length == 4 | ||
| && (b[0] == 127 || b[0] == 10 || (b[0] == 169 && b[1] == 254) | ||
| || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] == 16)); |
…te guard-literal comment, tighter NAT64 WKP example
|
Thanks for the review — all four points addressed in c2bb181:
|
| * Holds if `c` contains a hand-written denylist literal: an RFC 1918 / loopback / | ||
| * link-local-metadata IPv4 form (`127.0.0.1`, `169.254.169.254`, `10.`, `192.168`, the full | ||
| * `172.16.0.0/12` range `172.16`-`172.31`), an IPv6 loopback or ULA fragment (`::1`, `fc00`, | ||
| * `fd00`), or the cloud-metadata hostname fragment `metadata.google`. |
There was a problem hiding this comment.
What is metadata.google and why is it included as a special case?
| * literal, or a NAT64 / 6to4 / extract-embedded-IPv4 helper, means every transition family | ||
| * is accounted for rather than the `::ffff:0:0/96` prefix alone. | ||
| */ | ||
| predicate hasTransitionUnwrap(Callable c) { |
There was a problem hiding this comment.
The safe logic here seems too broad. The current hasTransitionUnwrap predicate may incorrectly suppress alerts just because it saw ::ffff: but doesn't handle 2002:. Is this intentional?
| predicate hasRfc1918Literal(Callable c) { | ||
| exists(StringLiteral s | s.getEnclosingCallable() = c | | ||
| s.getValue() | ||
| .regexpMatch("(?i).*(127\\.0\\.0\\.1|169\\.254\\.169\\.254|10\\.|192\\.168|172\\.(1[6-9]|2[0-9]|3[01])|::1|fc00|fd00|metadata\\.google).*") |
There was a problem hiding this comment.
This query seems to be missing some common IP addresses, including but not limited to:
127.0.0.0/8, not just 127.0.0.1
169.254.0.0/16, not just 169.254.169.254
localhost
0.0.0.0
| while still reaching the internal address. Calling | ||
| <code>IPAddress.MapToIPv4()</code> or testing | ||
| <code>IPAddress.IsIPv4MappedToIPv6</code> only canonicalizes the | ||
| <code>::ffff:0:0/96</code> prefix; NAT64, 6to4 and IPv4-compatible forms remain |
There was a problem hiding this comment.
IPv4-compatible IPv6 addresses (::/96) are deprecated. Please remove them from the PR contents. If you wish to include them, add to hasTransitionUnwrap
|
@tonghuaroot Thank you for the contribution. Just curious, what was the purpose of you creating this query. Were you able to use it to find security issues? |
Summary
Adds a new experimental C# query,
githubsecuritylab/cs/ssrf-ipv6-transition-incomplete-guard, that flags SSRF host-validation guards which reject private / loopback / cloud-metadata IPv4 ranges but never unwrap IPv6-transition representations.When a guard inspects only the dotted-quad IPv4 form, an attacker can wrap an internal IPv4 address in a transition literal so the validator classifies the host as public while the OS still routes the connection to the embedded internal endpoint. The affected forms are:
::ffff:169.254.169.25464:ff9b::a9fe:a9fe2002::A URL such as
http://[::ffff:169.254.169.254]/passes a dotted-quad denylist unchanged while still reaching the internal address. CallingIPAddress.MapToIPv4()/ testingIPAddress.IsIPv4MappedToIPv6only canonicalizes the::ffff:0:0/96prefix, so NAT64 / 6to4 / IPv4-compatible forms remain live (CWE-918 / CWE-1389).Origin
This was originally proposed in github/codeql#21964. @michaelnebel suggested moving it to the Community Packs rather than landing it as an experimental query in github/codeql, so this PR ports it here.
Contents
csharp/src/security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql— the query (@kind problem,import csharponly, autoformatted)csharp/src/security/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp— query helpcsharp/src/security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuard{Bad,Good}.cs— qhelp examplescsharp/test/security/CWE-918/SsrfIpv6TransitionIncompleteGuard/— unit test (.cs/.qlref/.expected/options)Adapted to Community Packs conventions:
@idnamespacegithubsecuritylab/cs/..., query undercsharp/src/security/CWE-918/, test undercsharp/test/security/CWE-918/with a${testdir}/.../codeql/...stuboptionspath matching the existing tests. The github/codeql-specific change-note and integration-test (not_included_in_qls.expected) files were dropped.Verification
Verified locally with CodeQL CLI 2.25.6:
codeql query compile csharp/src/security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql— compiles clean, no warnings.codeql query format— idempotent (autoformatted).codeql test run csharp/test/security/CWE-918/SsrfIpv6TransitionIncompleteGuard— passes (2 expected true positives flagged, 4 transition-aware / out-of-scope callables correctly suppressed).