From 95814847bdfd8447e9f436a4312e36d1915abf2b Mon Sep 17 00:00:00 2001 From: "@aaronjmars" <61592645+aaronjmars@users.noreply.github.com> Date: Mon, 4 May 2026 03:08:46 -0400 Subject: [PATCH] fix(security): block IPv4-compatible IPv6 and NAT64 SSRF bypasses in validateGitUrl (#1148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): block IPv4-compatible IPv6 and NAT64 SSRF bypasses Vulnerability: SSRF via IPv6 forms that embed IPv4 addresses Severity: high Location: gitnexus/src/server/git-clone.ts:assertNotPrivateIPv6 validateGitUrl() blocks ::ffff:x.x.x.x (IPv4-mapped) but two related forms still slipped through — both routable to the embedded IPv4 on common stacks: 1. IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated): http://[::127.0.0.1]/ — Node's URL parser collapses this to "::7f00:1" with no ::ffff: marker, so the existing check missed it. 2. NAT64 well-known prefix (RFC 6052: 64:ff9b::/96, plus RFC 8215's 64:ff9b:1::/48 local prefix): a host with NAT64 enabled translates 64:ff9b::7f00:1 to 127.0.0.1, reaching loopback. Impact: an attacker who can submit a clone URL to /api/analyze (any caller in the CORS-allowlisted origin set — localhost, RFC 1918 LAN, or gitnexus.vercel.app) could direct git clone at loopback or cloud metadata addresses (169.254.169.254 → ::a9fe:a9fe, 64:ff9b::a9fe:a9fe). Fix: extend assertNotPrivateIPv6 to reject any address compressed to ::xxxx[:yyyy] and any address starting with the NAT64 prefix 64:ff9b:. Tests added for both forms plus the cloud-metadata variants. * fix(security): block 6to4 SSRF bypass and add expanded-form regression tests Address review findings on PR #1148: - Block 6to4 (2002::/16, RFC 3056). The prefix encodes an IPv4 address in bits 17-48, so 2002:7f00:0001::* routes to 127.0.0.1 on 6to4-capable stacks. RFC 7526 deprecated the protocol and the public relay anycast has been retired, so broad-blocking has near-zero false-positive cost. - Expand the NAT64 comment to justify the broader-than-CIDR check: the whole 64:ff9b::/32 block is IANA-reserved for IPv4-IPv6 translation, so a future narrower CIDR refactor would silently re-open the bypass for 64:ff9b:1::/48 or any new translation range. - Add tests for expanded / zero-padded IPv4-compatible IPv6 forms ([0:0:0:0:0:0:7f00:1], fully zero-padded, mixed [0:...:127.0.0.1]). These pin the assumption that the WHATWG URL parser collapses these inputs to ::xxxx[:yyyy]; without them, a future Node anomaly would silently regress the bypass. - Add public IPv6 positive tests (Cloudflare 2606:4700::, Google 2001:4860::). Regression guard against over-blocking. - Add NAT64 + RFC1918 embedded-IP tests (10/8, 172.16/12, 192.168/16) to document SSRF coverage explicitly rather than relying on the prefix check. Co-Authored-By: Claude Opus 4.7 (1M context) * style: apply prettier formatting to git-clone.test.ts --------- Co-authored-by: aeonframework Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Gergo Magyar --- gitnexus/src/server/git-clone.ts | 33 ++++++++++++++ gitnexus/test/unit/git-clone.test.ts | 67 +++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 0f7bc2653..56c797d8a 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -139,6 +139,39 @@ function assertNotPrivateIPv6(ip: string): void { if (lower.includes(':ffff:')) { throw new Error('Cloning from private/internal addresses is not allowed'); } + + // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z). + // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4 + // is hidden in the last 32 bits without the ::ffff: marker, so the check + // above misses it. The form is still routable to the embedded IPv4 on most + // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked. + if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local + // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private + // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via + // e.g. 64:ff9b::7f00:1 → 127.0.0.1. + // The check intentionally covers the full 64:ff9b::/32 block (broader than + // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so + // blocking the whole prefix is defensively sound and prevents a narrower + // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any + // future translation assignment. + if (lower.startsWith('64:ff9b:')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so + // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The + // protocol was deprecated by RFC 7526 and the public relay anycast + // (192.88.99.1) has been retired, so broad-blocking the prefix has near- + // zero false-positive cost while closing the IPv4-embedded bypass. + // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is + // impractical and is out of scope here. + if (lower.startsWith('2002:')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } } function assertNotPrivateIPv4(ip: string): void { diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 832f95641..6b8e74556 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -96,8 +96,73 @@ describe('git-clone', () => { ); }); - it('does not block valid public IPs', () => { + it('blocks IPv4-compatible IPv6 (RFC 4291 deprecated, ::w.x.y.z)', () => { + // Node's URL parser collapses ::127.0.0.1 to ::7f00:1 — no ::ffff: marker, + // but still routable to 127.0.0.1 on most stacks. + expect(() => validateGitUrl('http://[::127.0.0.1]/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://[::7f00:1]/repo.git')).toThrow('private/internal'); + // 169.254.169.254 (cloud metadata) embedded as IPv4-compatible + expect(() => validateGitUrl('http://[::a9fe:a9fe]/repo.git')).toThrow('private/internal'); + }); + + it('blocks IPv4-compatible IPv6 in expanded / zero-padded forms', () => { + // The compressed-form check above relies on the WHATWG URL parser + // normalising fully-expanded inputs to ::xxxx[:yyyy]. These cases pin + // that assumption: if a future Node release stops collapsing them, a + // bypass would silently re-open without these tests catching it. + expect(() => validateGitUrl('http://[0:0:0:0:0:0:7f00:1]/repo.git')).toThrow( + 'private/internal', + ); + expect(() => + validateGitUrl('http://[0000:0000:0000:0000:0000:0000:7f00:0001]/repo.git'), + ).toThrow('private/internal'); + // Mixed notation: trailing IPv4 quad in an otherwise expanded address. + expect(() => validateGitUrl('http://[0:0:0:0:0:0:127.0.0.1]/repo.git')).toThrow( + 'private/internal', + ); + }); + + it('blocks NAT64 well-known prefix (64:ff9b::/96)', () => { + // 64:ff9b::7f00:1 → 127.0.0.1 via NAT64 translation + expect(() => validateGitUrl('http://[64:ff9b::7f00:1]/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://[64:ff9b::a9fe:a9fe]/repo.git')).toThrow( + 'private/internal', + ); + // RFC 8215 local NAT64 prefix + expect(() => validateGitUrl('http://[64:ff9b:1::1]/repo.git')).toThrow('private/internal'); + }); + + it('blocks NAT64 with embedded RFC1918 addresses', () => { + // The startsWith('64:ff9b:') check covers any embedded IPv4. These + // explicit RFC1918 cases document SSRF coverage for the full private + // IPv4 surface — not just loopback and cloud metadata. + expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1 + expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1 + expect(() => validateGitUrl('http://[64:ff9b::c0a8:101]/repo.git')).toThrow( + 'private/internal', + ); // 192.168.1.1 + }); + + it('blocks 6to4 prefix (2002::/16, RFC 3056)', () => { + // 6to4 encodes an IPv4 address in bits 17-48, so 2002:WWXX:YYZZ::* + // routes to W.X.Y.Z on 6to4-capable stacks. The protocol is deprecated + // (RFC 7526), so the entire 2002::/16 block is defensively rejected. + expect(() => validateGitUrl('http://[2002:7f00:1::1]/repo.git')).toThrow('private/internal'); // 127.0.0.1 + expect(() => validateGitUrl('http://[2002:a9fe:a9fe::1]/repo.git')).toThrow( + 'private/internal', + ); // 169.254.169.254 + expect(() => validateGitUrl('http://[2002:c0a8:101::1]/repo.git')).toThrow( + 'private/internal', + ); // 192.168.1.1 + }); + + it('does not block valid public IPs (IPv4 and IPv6)', () => { expect(() => validateGitUrl('https://140.82.121.4/repo.git')).not.toThrow(); + // Regression guard against over-blocking legitimate public IPv6. + // Cloudflare DNS (2606:4700::/32) and Google DNS (2001:4860::/32) — + // chosen because their prefixes don't collide with any block above. + expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow(); + expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow(); }); it('blocks CGN range (100.64.0.0/10)', () => {