From d101a69231f84df67d34a99b66941f9019c60ae7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 16:38:12 +0000 Subject: [PATCH] fix: block CGNAT addresses used to reach cloud metadata 100.64.0.0/10 was classified as a public destination, so a lookup or redirect could connect to Alibaba Cloud metadata at 100.100.100.200 and to other shared-address targets. Treat that range as private, including IPv4-mapped forms. Also retarget the Node.js process.getBuiltinModule docs link at the v22.12.0 page. The unversioned URL returns 404. Co-authored-by: Nick Jennings --- docs/SECURITY.md | 3 ++- src/webfinger.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++ src/webfinger.ts | 2 ++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2100684..2caa1d2 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,6 +25,7 @@ The following address ranges are blocked by default to prevent SSRF attacks: - `10.x.x.x` (Class A private) - `172.16.x.x` - `172.31.x.x` (Class B private) - `192.168.x.x` (Class C private) +- `100.64.x.x` - `100.127.x.x` (shared address space / CGNAT, RFC 6598). This includes the Alibaba Cloud instance metadata address `100.100.100.200` #### Link-Local Addresses - `169.254.x.x` (IPv4 link-local) @@ -50,7 +51,7 @@ In Node.js environments, the library performs DNS resolution to prevent attacks **Note**: This protection only applies in Node.js and Bun environments. Browser environments rely on the browser's built-in protections against private network access. -**Runtime requirements**: Node.js >= 22.12 or Bun is required (see the `engines` field in package.json). DNS resolution protection uses [`process.getBuiltinModule`](https://nodejs.org/api/process.html#processgetbuiltinmoduleid); on unsupported older Node.js versions this check is skipped and the library falls back to the address blocklist validation above — direct private IPs and hostnames are still blocked, but domains that *resolve* to private IPs are not. +**Runtime requirements**: Node.js >= 22.12 or Bun is required (see the `engines` field in package.json). DNS resolution protection uses [`process.getBuiltinModule`](https://nodejs.org/docs/v22.12.0/api/process.html#processgetbuiltinmoduleid); on unsupported older Node.js versions this check is skipped and the library falls back to the address blocklist validation above — direct private IPs and hostnames are still blocked, but domains that *resolve* to private IPs are not. ### Redirect Protection diff --git a/src/webfinger.test.ts b/src/webfinger.test.ts index b4529c5..0fa0152 100644 --- a/src/webfinger.test.ts +++ b/src/webfinger.test.ts @@ -173,6 +173,67 @@ describe('WebFinger', () => { await expect(secureWebfinger.lookup('test@169.254.169.254')).rejects.toThrow('private or internal addresses are not allowed'); }); + it('should block shared address space before network access (100.64.0.0/10)', async () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + + globalThis.fetch = async () => { + fetchCalled = true; + throw new Error('Network request should not be made for blocked private hosts'); + }; + + try { + const blocked = [ + 'test@100.64.0.1', + 'test@100.100.100.200', + 'test@100.127.255.254', + 'test@[::ffff:100.100.100.200]', + 'https://100.100.100.200/latest/meta-data/' + ]; + + for (const address of blocked) { + await expect(secureWebfinger.lookup(address)) + .rejects.toThrow('private or internal addresses are not allowed'); + } + + expect(fetchCalled).toBe(false); + + globalThis.fetch = async () => new Response(null, { + status: 302, + headers: { location: 'http://100.100.100.200/latest/meta-data/ram/security-credentials/' } + }); + + await expect(secureWebfinger.lookup('test@example.com')) + .rejects.toThrow('redirect to private or internal address blocked'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should allow addresses outside shared address space (100.64.0.0/10)', async () => { + const originalFetch = globalThis.fetch; + const requestedUrls: string[] = []; + + globalThis.fetch = async (url: string | Request) => { + requestedUrls.push(typeof url === 'string' ? url : url.url); + return new Response(JSON.stringify({ subject: 'acct:test', links: [] }), { + status: 200, + headers: { 'content-type': 'application/jrd+json' } + }); + }; + + try { + await secureWebfinger.lookup('test@100.63.255.255'); + await secureWebfinger.lookup('test@100.128.0.1'); + expect(requestedUrls).toEqual([ + 'https://100.63.255.255/.well-known/webfinger?resource=acct:test@100.63.255.255', + 'https://100.128.0.1/.well-known/webfinger?resource=acct:test@100.128.0.1' + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('should block multicast addresses (224-239.x.x.x)', async () => { await expect(secureWebfinger.lookup('test@224.0.0.1')).rejects.toThrow('private or internal addresses are not allowed'); await expect(secureWebfinger.lookup('test@239.255.255.255')).rejects.toThrow('private or internal addresses are not allowed'); diff --git a/src/webfinger.ts b/src/webfinger.ts index 7642863..716b2aa 100644 --- a/src/webfinger.ts +++ b/src/webfinger.ts @@ -102,6 +102,7 @@ function isPrivateIPv4(octets: number[]): boolean { (a === 172 && b >= 16 && b <= 31) || // Private (a === 192 && b === 168) || // Private (a === 169 && b === 254) || // Link-local + (a === 100 && b >= 64 && b <= 127) || // Shared address space (RFC 6598) (a >= 224 && a <= 239) || // Multicast a >= 240; // Reserved } @@ -366,6 +367,7 @@ export default class WebFinger { * Blocks the following address ranges: * - Localhost/unspecified: localhost, 0.0.0.0/8, 127.x.x.x, ::, ::1, localhost.localdomain * - Private IPv4: 10.x.x.x, 172.16-31.x.x, 192.168.x.x + * - Shared address space: 100.64.0.0/10 (CGNAT, Alibaba Cloud metadata) * - Link-local: 169.254.x.x, fe80::/10 * - Multicast: 224.x.x.x-239.x.x.x, ff00::/8 * - IPv4-mapped and IPv4-compatible IPv6 forms of blocked IPv4 addresses