Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
61 changes: 61 additions & 0 deletions src/webfinger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions src/webfinger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Loading