CVE-2025-54590
Description
webfinger.js is a TypeScript-based WebFinger client that runs in both browsers and Node.js environments. In versions 2.8.0 and below, the lookup function accepts user addresses for account checking. However, the ActivityPub specification requires preventing access to localhost services in production. This library does not prevent localhost access, only checking for hosts that start with "localhost" and end with a port. Users can exploit this by creating servers that send GET requests with controlled host, path, and port parameters to query services on the instance's host or local network, enabling blind SSRF attacks. This is fixed in version 2.8.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
webfinger.jsnpm | < 2.8.1 | 2.8.1 |
Affected products
1- Range: v0.1.2, v0.1.3, v0.1.4, …
Patches
2b5f2f2c95729Security Fix Blind SSRF (#82)
7 files changed · +1294 −167
docs/SECURITY.md+114 −0 added@@ -0,0 +1,114 @@ +# Security + +webfinger.js prioritizes security and includes comprehensive protection against common attack vectors that can affect WebFinger implementations. + +## SSRF Protection + +This library includes robust protection against Server-Side Request Forgery (SSRF) attacks by default: + +- **Private address blocking**: Prevents requests to localhost, private IP ranges, and internal networks +- **DNS resolution protection**: Resolves domain names in Node.js environments to block domains that resolve to private IPs +- **Path injection prevention**: Validates host formats to prevent directory traversal attacks +- **Redirect validation**: Prevents redirect-based SSRF attacks to private networks +- **ActivityPub compliance**: Follows [ActivityPub security guidelines](https://www.w3.org/TR/activitypub/#security-considerations) (Section B.3) + +### Blocked Addresses + +The following address ranges are blocked by default to prevent SSRF attacks: + +#### Localhost +- `localhost`, `localhost.localdomain` +- `127.x.x.x` (IPv4 loopback) +- `::1` (IPv6 loopback) + +#### Private IPv4 Ranges +- `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) + +#### Link-Local Addresses +- `169.254.x.x` (IPv4 link-local) +- `fe80::/10` (IPv6 link-local) + +#### Multicast Addresses +- `224.x.x.x` - `239.x.x.x` (IPv4 multicast) +- `ff00::/8` (IPv6 multicast) + +### DNS Resolution Protection + +In Node.js environments, the library performs DNS resolution to prevent attacks using domains that resolve to private IP addresses: + +- **Domain resolution**: All domain names are resolved to IP addresses before making requests +- **Private IP detection**: Resolved IPs are checked against the private address blacklist +- **Attack prevention**: Blocks requests to public domains like `localtest.me` that resolve to `127.0.0.1` +- **Browser compatibility**: DNS resolution is skipped in browser environments where it's not available + +**Example blocked domains:** +- `localtest.me` → `127.0.0.1` (blocked) +- `10.0.0.1.nip.io` → `10.0.0.1` (blocked) +- Custom domains configured to resolve to private networks + +**Note**: This protection only applies in Node.js environments. Browser environments rely on the browser's built-in protections against private network access. + +### Redirect Protection + +The library implements manual redirect handling to validate redirect destinations: + +- **Redirect limits**: Maximum of 3 redirects to prevent redirect loops +- **Destination validation**: All redirect targets are checked against the private address blacklist +- **Malformed response handling**: Invalid or missing Location headers are rejected +- **URL validation**: Redirect URLs are parsed and validated before following + +This prevents attacks where a public domain's WebFinger endpoint redirects to private network resources. + +## Development Override + +⚠️ **CAUTION**: The following configuration should **ONLY** be used in development or testing environments! + +```typescript +const webfinger = new WebFinger({ + allow_private_addresses: true // Disables SSRF protection - DANGEROUS in production! +}); + +// This will now work (but should never be used in production) +await webfinger.lookup('user@localhost:3000'); +``` + +### When to Use Development Override + +- **Local development**: Testing against localhost services +- **Internal testing**: Validating against private network services +- **Unit testing**: Creating controlled test environments + +### Production Security + +**Never** set `allow_private_addresses: true` in production environments. This completely disables SSRF protection and opens your application to serious security vulnerabilities. + +## Security Best Practices + +When integrating webfinger.js into your application: + +1. **Keep defaults**: Use the default secure configuration in production +2. **Validate inputs**: Always validate user-provided addresses before lookup +3. **Handle errors gracefully**: Don't expose internal network details in error messages +4. **Monitor requests**: Log WebFinger lookups for security monitoring +5. **Update regularly**: Keep the library updated to receive security patches + +## Reporting Security Issues + +If you discover a security vulnerability in webfinger.js, please report it responsibly: + +1. **Do not** create a public GitHub issue +2. Email security concerns to the maintainer +3. Include detailed reproduction steps +4. Allow reasonable time for fixes before public disclosure + +## Compliance + +This library's security implementation follows: + +- [ActivityPub Security Considerations](https://www.w3.org/TR/activitypub/#security-considerations) (Section B.3) +- [RFC 7033 WebFinger](https://tools.ietf.org/html/rfc7033) security guidelines +- Common SSRF prevention best practices + +The security model is designed to be safe by default while providing necessary flexibility for legitimate use cases. \ No newline at end of file
README.md+7 −1 modified@@ -10,7 +10,8 @@ A modern, TypeScript-based WebFinger client that runs in both browsers and Node. ## Features ✨ **Modern ES6+ support** - Built with TypeScript, works with modern JavaScript -🔒 **Security-first** - Defaults to TLS-only connections +🔒 **Security-first** - SSRF protection, blocks private/internal addresses by default +🛡️ **Production-ready** - Prevents localhost/LAN access per ActivityPub security guidelines 🔄 **Flexible fallbacks** - Supports host-meta and WebFist fallback mechanisms 🌐 **Universal** - Works in browsers and Node.js 📦 **Zero dependencies** - Lightweight and self-contained @@ -64,6 +65,11 @@ bun run lint # Code linting See the [Development Guide](docs/DEVELOPMENT.md) for detailed testing information and individual test commands. +## Security + +webfinger.js includes comprehensive SSRF protection, blocking private networks and validating redirects by default. For detailed security information, see **[Security Documentation](docs/SECURITY.md)**. + + ## Contributing Contributions are welcome! Please see the [Development Guide](docs/DEVELOPMENT.md) for setup instructions, coding guidelines, and contribution workflow.
spec/browser/browser.integration.js+49 −9 modified@@ -26,7 +26,8 @@ describe('WebFinger Browser Tests', () => { webfinger = new WebFinger({ webfist_fallback: true, uri_fallback: true, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true // Allow localhost for browser tests }); }); @@ -107,7 +108,8 @@ describe('WebFinger Browser Tests', () => { it('should perform successful WebFinger lookup with mock server', async () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); const result = await wf.lookup(`test@localhost:${serverPort}`); @@ -120,7 +122,8 @@ describe('WebFinger Browser Tests', () => { it('should handle server errors gracefully', async () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); try { @@ -150,7 +153,8 @@ describe('WebFinger Browser Tests', () => { it('should return properly structured JRD response', async () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); const result = await wf.lookup(`test@localhost:${serverPort}`); @@ -170,7 +174,8 @@ describe('WebFinger Browser Tests', () => { it('should handle JRD with properties', async () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); const result = await wf.lookup(`user@localhost:${serverPort}`); @@ -198,7 +203,8 @@ describe('WebFinger Browser Tests', () => { it('should find specific link relations', async () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); const profileLink = await wf.lookupLink(`test@localhost:${serverPort}`, 'profile'); @@ -211,7 +217,8 @@ describe('WebFinger Browser Tests', () => { it('should return null for non-existent link relations', async () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); try { @@ -242,7 +249,8 @@ describe('WebFinger Browser Tests', () => { const wf = new WebFinger({ tls_only: false, uri_fallback: true, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true }); const result = await wf.lookup(`test@localhost:${serverPort}`); @@ -256,7 +264,8 @@ describe('WebFinger Browser Tests', () => { const wf = new WebFinger({ tls_only: false, - request_timeout: 1000 + request_timeout: 1000, + allow_private_addresses: true }); try { @@ -267,6 +276,37 @@ describe('WebFinger Browser Tests', () => { } }); }); + + describe('Security Features', () => { + it('should block private addresses when allow_private_addresses is false', async () => { + const secureWf = new WebFinger({ + tls_only: false, + allow_private_addresses: false, // Security enabled + request_timeout: 1000 + }); + + try { + await secureWf.lookup('test@localhost:8080'); + throw new Error('Should have thrown'); + } catch (err) { + expect(err.message).to.include('private or internal addresses are not allowed'); + } + }); + + it('should block private IPv4 addresses', async () => { + const secureWf = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + try { + await secureWf.lookup('test@192.168.1.1'); + throw new Error('Should have thrown'); + } catch (err) { + expect(err.message).to.include('private or internal addresses are not allowed'); + } + }); + }); }); // Mock server creation function
spec/integration/local-server.integration.ts+8 −4 modified@@ -16,7 +16,8 @@ describe('WebFinger Controlled Tests', () => { tls_only: false, // Use HTTP for test server webfist_fallback: false, uri_fallback: true, - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true // Allow localhost for integration tests }); }); @@ -71,7 +72,8 @@ describe('WebFinger Controlled Tests', () => { tls_only: false, webfist_fallback: false, uri_fallback: false, // Disable fallbacks to test direct 404 - request_timeout: 5000 + request_timeout: 5000, + allow_private_addresses: true // Allow localhost for integration tests }); await expect(noFallbackWebfinger.lookup(`nonexistent@localhost:${serverPort}`)) @@ -95,7 +97,8 @@ describe('WebFinger Controlled Tests', () => { tls_only: false, uri_fallback: true, webfist_fallback: false, - request_timeout: 3000 + request_timeout: 3000, + allow_private_addresses: true // Allow localhost for integration tests }); // Should work even if first endpoint fails @@ -108,7 +111,8 @@ describe('WebFinger Controlled Tests', () => { tls_only: false, uri_fallback: false, webfist_fallback: false, - request_timeout: 3000 + request_timeout: 3000, + allow_private_addresses: true // Allow localhost for integration tests }); const result = await noFallbackWf.lookup(`test@localhost:${serverPort}`);
spec/integration/real-servers.integration.ts+354 −93 modified@@ -1,105 +1,335 @@ -import { describe, it, expect, beforeAll } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import WebFinger from '../../src/webfinger'; -const getErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : String(error); +// Type for mocking Node.js process object in tests +type MockProcess = { + versions: { + node: string; + }; +}; + describe('WebFinger Integration Tests', () => { - let webfinger: WebFinger; + let originalFetch: typeof globalThis.fetch; - beforeAll(() => { - webfinger = new WebFinger({ - webfist_fallback: true, - uri_fallback: true, - request_timeout: 10000 // Longer timeout for real network requests - }); + beforeEach(() => { + originalFetch = globalThis.fetch; }); - describe('Real WebFinger Servers', () => { - it('should successfully lookup a known working WebFinger address', async () => { - try { - const result = await webfinger.lookup('nick@silverbucket.net'); - expect(result).toBeDefined(); - expect(result.object).toBeDefined(); - expect(result.idx).toBeDefined(); - expect(result.idx.links).toBeDefined(); - } catch (error) { - // If the server is down, skip this test - console.warn('Skipping integration test - server may be down:', getErrorMessage(error)); - } - }, 15000); + afterEach(() => { + // Restore original fetch after each test + globalThis.fetch = originalFetch; + }); - it('should handle WebFinger lookupLink for known address', async () => { - try { - const result = await webfinger.lookupLink('nick@silverbucket.net', 'profile'); - expect(result).toBeDefined(); - expect(result.href).toBeDefined(); - } catch (error) { - const errorMessage = getErrorMessage(error); - if (errorMessage.includes('no links found')) { - // This is acceptable - the address may not have this link type - expect(errorMessage).toContain('no links found'); - } else { - // Server may be down, skip - console.warn('Skipping integration test - server may be down:', getErrorMessage(error)); - } - } - }, 15000); + describe('Mocked WebFinger Response Handling', () => { + it('should successfully process a valid WebFinger response', async () => { + // Mock a successful WebFinger response + globalThis.fetch = async () => { + return new Response(JSON.stringify({ + subject: 'acct:test@example.com', + links: [ + { + rel: 'http://webfinger.net/rel/profile-page', + href: 'https://example.com/profile' + }, + { + rel: 'http://webfinger.net/rel/avatar', + href: 'https://example.com/avatar.png' + } + ] + }), { + status: 200, + headers: { 'content-type': 'application/jrd+json' } + }); + }; - it('should handle another known WebFinger address', async () => { - try { - const result = await webfinger.lookup('paulej@packetizer.com'); - expect(result).toBeDefined(); - expect(result.object).toBeDefined(); - expect(result.idx).toBeDefined(); - } catch (error) { - console.warn('Skipping integration test - server may be down:', getErrorMessage(error)); - } - }, 15000); + // Create WebFinger instance after mocking fetch + const webfinger = new WebFinger({ + allow_private_addresses: true, // Allow example.com for testing + request_timeout: 1000 + }); + + const result = await webfinger.lookup('test@example.com'); + expect(result).toBeDefined(); + expect(result.object).toBeDefined(); + expect(result.object.subject).toBe('acct:test@example.com'); + expect(result.idx).toBeDefined(); + expect(result.idx.links).toBeDefined(); + expect(result.idx.links.profile).toBeDefined(); + expect(result.idx.links.profile.length).toBeGreaterThan(0); + expect(result.idx.links.profile[0].href).toBe('https://example.com/profile'); + }); + + it('should handle WebFinger lookupLink for specific relations', async () => { + // Mock a WebFinger response with profile link + globalThis.fetch = async () => { + return new Response(JSON.stringify({ + subject: 'acct:test@example.com', + links: [ + { + rel: 'http://webfinger.net/rel/profile-page', + href: 'https://example.com/profile', + type: 'text/html' + } + ] + }), { + status: 200, + headers: { 'content-type': 'application/jrd+json' } + }); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); + + const result = await webfinger.lookupLink('test@example.com', 'profile'); + expect(result).toBeDefined(); + expect(result.href).toBe('https://example.com/profile'); + expect(result.rel).toBe('http://webfinger.net/rel/profile-page'); + }); + + it('should handle responses with properties', async () => { + // Mock a WebFinger response with properties + globalThis.fetch = async () => { + return new Response(JSON.stringify({ + subject: 'acct:user@example.com', + properties: { + 'http://packetizer.com/ns/name': 'Test User' + }, + links: [] + }), { + status: 200, + headers: { 'content-type': 'application/jrd+json' } + }); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); + + const result = await webfinger.lookup('user@example.com'); + expect(result).toBeDefined(); + expect(result.idx.properties.name).toBe('Test User'); + }); }); describe('Network Error Scenarios', () => { - it('should handle complete network failures gracefully', async () => { - const testWf = new WebFinger({ - uri_fallback: false, - webfist_fallback: false, - request_timeout: 2000 + it('should handle 404 responses gracefully', async () => { + // Mock a 404 response + globalThis.fetch = async () => { + return new Response('Not Found', { + status: 404, + statusText: 'Not Found' + }); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 }); - await expect(testWf.lookup('test@completely-nonexistent-domain-12345.invalid')) - .rejects.toThrow(); + await expect(webfinger.lookup('test@nonexistent.example')) + .rejects.toThrow('resource not found'); }); - it('should handle domains with no WebFinger support', async () => { - const testWf = new WebFinger({ - uri_fallback: true, - webfist_fallback: false, - request_timeout: 5000 + it('should handle server errors (500)', async () => { + // Mock a 500 server error response + globalThis.fetch = async () => { + return new Response('Internal Server Error', { + status: 500, + statusText: 'Internal Server Error' + }); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 }); - // Google doesn't support WebFinger - await expect(testWf.lookup('test@google.com')) - .rejects.toThrow(); + await expect(webfinger.lookup('test@error.example')) + .rejects.toThrow('error during request'); + }); + + it('should handle network connection failures', async () => { + // Mock a network failure + globalThis.fetch = async () => { + throw new Error('Network connection failed'); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); + + await expect(webfinger.lookup('test@unreachable.example')) + .rejects.toThrow('Network connection failed'); + }); + + it('should handle malformed JSON responses', async () => { + // Mock a response with invalid JSON + globalThis.fetch = async () => { + return new Response('{ invalid json }', { + status: 200, + headers: { 'content-type': 'application/jrd+json' } + }); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); + + await expect(webfinger.lookup('test@badjson.example')) + .rejects.toThrow('invalid json'); }); it('should handle malformed addresses consistently', async () => { - const testWf = new WebFinger({ request_timeout: 1000 }); + // Mock fetch to prevent any real network requests + globalThis.fetch = async () => { + throw new Error('Network request should not be made for validation errors'); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); - await expect(testWf.lookup('not-an-email')) + // These should fail validation before any network requests + await expect(webfinger.lookup('not-an-email')) .rejects.toThrow('invalid useraddress format'); - await expect(testWf.lookup('')) + await expect(webfinger.lookup('')) .rejects.toThrow('address is required'); - // This may throw different errors depending on DNS resolution + // @nodomain actually passes useraddress format validation (parts[1] = 'nodomain') + // but fails because 'nodomain' isn't a real domain - expect network error instead + await expect(webfinger.lookup('@nodomain')) + .rejects.toThrow('Network request should not be made for validation errors'); + }); + + it('should handle missing links gracefully', async () => { + // Mock a response with no matching links + globalThis.fetch = async () => { + return new Response(JSON.stringify({ + subject: 'acct:test@example.com', + links: [] + }), { + status: 200, + headers: { 'content-type': 'application/jrd+json' } + }); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); + + await expect(webfinger.lookupLink('test@example.com', 'blog')) + .rejects.toThrow('no links found with rel="blog"'); + }); + }); + + describe('DNS Resolution SSRF Protection - Mocked Tests', () => { + it('should block domains that resolve to localhost via mocked DNS', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + + // Set up Node.js environment simulation + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + const mockDns = { + resolve4: async (hostname: string) => { + dnsResolveCalled = true; + if (hostname === 'malicious.example') { + return ['127.0.0.1']; // Return localhost IP to trigger SSRF protection + } + return []; + }, + resolve6: async () => [] + }; + + // Mock eval to return our mock DNS module + global.eval = (code: string) => { + if (code.includes('import("dns")')) { + return Promise.resolve({ promises: mockDns }); + } + return originalEval(code); + }; + try { - await testWf.lookup('@nodomain'); - throw new Error('Should have thrown an error'); - } catch (error) { - expect(error).toBeDefined(); - // Could be validation error or network error + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000, + uri_fallback: false, + webfist_fallback: false + }); + + await expect(secureWebfinger.lookup('test@malicious.example')) + .rejects.toThrow('resolves to private address'); + + expect(dnsResolveCalled).toBe(true); + } finally { + global.eval = originalEval; + global.process = originalProcess; } }); + + it('should allow domains that resolve to public IPs via mocked DNS', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + const mockDns = { + resolve4: async (hostname: string) => { + dnsResolveCalled = true; + if (hostname === 'safe.example') { + return ['8.8.8.8']; // Return public DNS IP + } + return []; + }, + resolve6: async () => [] + }; + + global.eval = (code: string) => { + if (code.includes('import("dns")')) { + return Promise.resolve({ promises: mockDns }); + } + return originalEval(code); + }; + + // Mock fetch to simulate the subsequent WebFinger request failing (expected) + globalThis.fetch = async () => { + throw new Error('No WebFinger endpoint'); + }; + + try { + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000, + uri_fallback: false, + webfist_fallback: false + }); + + // Should fail with network error, not DNS security error + await expect(secureWebfinger.lookup('test@safe.example')) + .rejects.toThrow('No WebFinger endpoint'); + + expect(dnsResolveCalled).toBe(true); + } finally { + global.eval = originalEval; + global.process = originalProcess; + } + }); + + it('should verify DNS resolution is working in Node.js environment', () => { + const hasNodeProcess = typeof process !== 'undefined' && process.versions?.node; + expect(hasNodeProcess).toBeTruthy(); + }); }); describe('Configuration Testing', () => { @@ -133,26 +363,57 @@ describe('WebFinger Integration Tests', () => { describe('Response Format Validation', () => { it('should validate that successful responses have correct structure', async () => { - try { - const result = await webfinger.lookup('nick@silverbucket.net'); - - // Validate response structure - expect(result).toHaveProperty('object'); - expect(result).toHaveProperty('idx'); - expect(result.idx).toHaveProperty('links'); - expect(result.idx).toHaveProperty('properties'); - - // Validate that links is an object with expected properties - expect(typeof result.idx.links).toBe('object'); - - // Validate that each link type is an array - Object.keys(result.idx.links).forEach(linkType => { - expect(Array.isArray(result.idx.links[linkType])).toBe(true); + // Mock a comprehensive WebFinger response + globalThis.fetch = async () => { + return new Response(JSON.stringify({ + subject: 'acct:test@example.com', + properties: { + 'http://packetizer.com/ns/name': 'Test User' + }, + links: [ + { + rel: 'http://webfinger.net/rel/profile-page', + href: 'https://example.com/profile', + type: 'text/html' + }, + { + rel: 'http://webfinger.net/rel/avatar', + href: 'https://example.com/avatar.png', + type: 'image/png' + } + ] + }), { + status: 200, + headers: { 'content-type': 'application/jrd+json' } }); - - } catch (error) { - console.warn('Skipping validation test - server may be down:', getErrorMessage(error)); - } - }, 10000); + }; + + const webfinger = new WebFinger({ + allow_private_addresses: true, + request_timeout: 1000 + }); + + const result = await webfinger.lookup('test@example.com'); + + // Validate response structure + expect(result).toHaveProperty('object'); + expect(result).toHaveProperty('idx'); + expect(result.idx).toHaveProperty('links'); + expect(result.idx).toHaveProperty('properties'); + + // Validate that links is an object with expected properties + expect(typeof result.idx.links).toBe('object'); + + // Validate that each link type is an array + Object.keys(result.idx.links).forEach(linkType => { + expect(Array.isArray(result.idx.links[linkType])).toBe(true); + }); + + // Validate specific content + expect(result.object.subject).toBe('acct:test@example.com'); + expect(result.idx.properties.name).toBe('Test User'); + expect(result.idx.links.profile[0].href).toBe('https://example.com/profile'); + expect(result.idx.links.avatar[0].href).toBe('https://example.com/avatar.png'); + }); }); }); \ No newline at end of file
src/webfinger.test.ts+417 −37 modified@@ -1,6 +1,13 @@ import { describe, it, expect, beforeAll } from 'bun:test'; import WebFinger from '../src/webfinger'; +// Type for mocking Node.js process object in tests +type MockProcess = { + versions: { + node: string; + }; +}; + describe('WebFinger', () => { let webfinger: WebFinger; @@ -75,46 +82,13 @@ describe('WebFinger', () => { }); }); - describe('Network Error Handling', () => { - it('should handle non-existent domains gracefully', async () => { - const testWf = new WebFinger({ - uri_fallback: false, - webfist_fallback: false, - request_timeout: 1000 - }); - - // This should fail with a network error or 404 - await expect(testWf.lookup('test@nonexistentdomain12345.com')).rejects.toThrow(); - }, 5000); - - it('should handle domains without WebFinger support', async () => { - const testWf = new WebFinger({ - uri_fallback: true, - request_timeout: 3000 - }); - - // Gmail doesn't support WebFinger, should error - await expect(testWf.lookup('test@gmail.com')).rejects.toThrow(); - }); - }); describe('lookupLink method', () => { it('should reject for unsupported link relations', async () => { await expect(webfinger.lookupLink('test@example.com', 'unsupported-rel')) .rejects.toThrow('unsupported rel'); }); - it('should accept known link relations', async () => { - // This will likely fail with network error, but should not reject due to unsupported rel - const testWf = new WebFinger({ request_timeout: 1000 }); - - try { - await testWf.lookupLink('test@nonexistent12345.com', 'avatar'); - } catch (error) { - // Should fail with network error, not unsupported rel error - expect(error.message).not.toContain('unsupported rel'); - } - }); }); describe('Error Types', () => { @@ -128,6 +102,400 @@ describe('WebFinger', () => { }); }); + describe('Security Features', () => { + describe('Private Address Blocking', () => { + let secureWebfinger: WebFinger; + + beforeAll(() => { + secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + }); + + it('should block private IPv4 addresses (10.x.x.x)', async () => { + await expect(secureWebfinger.lookup('test@10.0.0.1')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block private IPv4 addresses (192.168.x.x)', async () => { + await expect(secureWebfinger.lookup('test@192.168.1.1')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block private IPv4 addresses (172.16-31.x.x)', async () => { + await expect(secureWebfinger.lookup('test@172.16.0.1')).rejects.toThrow('private or internal addresses are not allowed'); + await expect(secureWebfinger.lookup('test@172.31.255.255')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block localhost addresses', async () => { + await expect(secureWebfinger.lookup('test@127.0.0.1')).rejects.toThrow('private or internal addresses are not allowed'); + await expect(secureWebfinger.lookup('test@127.1.1.1')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block link-local addresses (169.254.x.x)', async () => { + await expect(secureWebfinger.lookup('test@169.254.169.254')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + 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'); + }); + + it('should block reserved addresses (240+.x.x.x)', async () => { + await expect(secureWebfinger.lookup('test@240.0.0.1')).rejects.toThrow('private or internal addresses are not allowed'); + await expect(secureWebfinger.lookup('test@255.255.255.255')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block IPv6 localhost', async () => { + await expect(secureWebfinger.lookup('test@[::1]')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block IPv6 private addresses', async () => { + await expect(secureWebfinger.lookup('test@[fc00::1]')).rejects.toThrow('private or internal addresses are not allowed'); + await expect(secureWebfinger.lookup('test@[fd12:3456:789a::1]')).rejects.toThrow('private or internal addresses are not allowed'); + await expect(secureWebfinger.lookup('test@[fe80::1]')).rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should have allow_private_addresses configuration option', () => { + const permissiveWf = new WebFinger({ + allow_private_addresses: true + }); + + expect(permissiveWf.config.allow_private_addresses).toBe(true); + }); + + it('should default to blocking private addresses', () => { + const defaultWf = new WebFinger(); + expect(defaultWf.config.allow_private_addresses).toBe(false); + }); + }); + + describe('SSRF Prevention (CVE Advisory Tests)', () => { + it('should block the original PoC attack vector', async () => { + // Test the exact attack vector from GHSA-8xq3-w9fx-74rv + const maliciousAddress = 'user@localhost:1234/secret.txt?'; + + await expect(webfinger.lookup(maliciousAddress)) + .rejects.toThrow('private or internal addresses are not allowed'); + }); + + it('should block localhost with port and path combinations', async () => { + const attackVectors = [ + 'user@localhost:8080/admin', + 'user@127.0.0.1:3000/api/internal', + 'user@localhost:1234/secret.txt?', + 'user@127.0.0.1:9000/config', + 'user@localhost:7000/admin/restricted' + ]; + + for (const maliciousAddress of attackVectors) { + await expect(webfinger.lookup(maliciousAddress)) + .rejects.toThrow('private or internal addresses are not allowed'); + } + }); + + it('should block internal network probing attempts', async () => { + const internalProbes = [ + 'user@192.168.1.100:22/ssh', + 'user@10.0.0.1:80/admin', + 'user@172.16.0.1:443/internal', + 'user@169.254.169.254:80/latest/meta-data' // AWS metadata service + ]; + + for (const probe of internalProbes) { + await expect(webfinger.lookup(probe)) + .rejects.toThrow('private or internal addresses are not allowed'); + } + }); + }); + + describe('DNS Resolution SSRF Protection', () => { + it('should have DNS resolution protection available in Node.js environments', () => { + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + // We can't easily test actual DNS resolution without external dependencies, + // but we can verify the WebFinger instance is properly configured + expect(secureWebfinger).toBeDefined(); + }); + + it('should perform DNS resolution in Node.js environment and block private IPs', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + + // Set up Node.js environment simulation + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + let resolvedHostname = ''; + + const mockDns = { + resolve4: async (hostname: string) => { + dnsResolveCalled = true; + resolvedHostname = hostname; + return ['127.0.0.1']; // Return localhost IP to trigger SSRF protection + }, + resolve6: async () => [] + }; + + // Mock eval to return our mock DNS module + global.eval = (code: string) => { + if (code.includes('import("dns")')) { + return Promise.resolve({ promises: mockDns }); + } + return originalEval(code); + }; + + try { + const webfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + // Test DNS validation through the lookup method + await expect(webfinger.lookup('test@malicious-domain.com')) + .rejects.toThrow('resolves to private address'); + + // Verify DNS was called + expect(dnsResolveCalled).toBe(true); + expect(resolvedHostname).toBe('malicious-domain.com'); + } finally { + // Restore original functions + global.eval = originalEval; + global.process = originalProcess; + } + }); + + it('should skip DNS resolution for IP addresses', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + + // Set up Node.js environment + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + const mockDns = { + resolve4: async () => { + dnsResolveCalled = true; + return []; + }, + resolve6: async () => [] + }; + + global.eval = () => Promise.resolve({ promises: mockDns }); + + try { + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + // Should be blocked by isPrivateAddress, not DNS resolution + await expect(secureWebfinger.lookup('test@127.0.0.1')) + .rejects.toThrow('private or internal addresses are not allowed'); + + // DNS should not have been called since it's already an IP + expect(dnsResolveCalled).toBe(false); + } finally { + global.eval = originalEval; + global.process = originalProcess; + } + }); + + it('should skip DNS resolution for localhost', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + const mockDns = { + resolve4: async () => { + dnsResolveCalled = true; + return []; + }, + resolve6: async () => [] + }; + + global.eval = () => Promise.resolve({ promises: mockDns }); + + try { + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + await expect(secureWebfinger.lookup('test@localhost')) + .rejects.toThrow('private or internal addresses are not allowed'); + + // DNS should not have been called for localhost + expect(dnsResolveCalled).toBe(false); + } finally { + global.eval = originalEval; + global.process = originalProcess; + } + }); + + it('should allow domains that resolve to public IPs', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + const originalFetch = globalThis.fetch; + + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + const mockDns = { + resolve4: async () => { + dnsResolveCalled = true; + return ['8.8.8.8']; // Public DNS server IP + }, + resolve6: async () => [] + }; + + global.eval = () => Promise.resolve({ promises: mockDns }); + + // Mock fetch to simulate network request + globalThis.fetch = () => Promise.reject(new Error('Network error')); + + try { + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + // Should fail with network error, not DNS security error + await expect(secureWebfinger.lookup('test@public-domain.com')) + .rejects.toThrow('Network error'); + + expect(dnsResolveCalled).toBe(true); + } finally { + global.eval = originalEval; + global.process = originalProcess; + globalThis.fetch = originalFetch; + } + }); + + it('should not perform DNS resolution in browser environments', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + const originalFetch = globalThis.fetch; + + // Simulate browser environment (no process.versions.node) + delete (global as Record<string, unknown>).process; + + let evalCalled = false; + global.eval = () => { + evalCalled = true; + return Promise.resolve({ promises: null }); + }; + + globalThis.fetch = () => Promise.reject(new Error('Network error')); + + try { + const secureWebfinger = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + // Should fail with network error, DNS resolution should be skipped + await expect(secureWebfinger.lookup('test@example.com')) + .rejects.toThrow('Network error'); + + // eval should not have been called in browser environment + expect(evalCalled).toBe(false); + } finally { + global.eval = originalEval; + global.process = originalProcess; + globalThis.fetch = originalFetch; + } + }); + + it('should allow private addresses when allow_private_addresses is true', async () => { + const permissiveWf = new WebFinger({ + allow_private_addresses: true, + request_timeout: 100 + }); + + // These should not be blocked by DNS validation when private addresses are allowed + // Test with localhost which should be allowed through due to config + try { + await permissiveWf.lookup('test@localhost'); + } catch (error) { + // Should fail with network error, not DNS resolution security error + expect(error.message).not.toContain('resolves to private address'); + expect(error.message).not.toContain('private or internal addresses are not allowed'); + } + }); + + it('should skip DNS resolution when allow_private_addresses is true', async () => { + const originalEval = global.eval; + const originalProcess = global.process; + const originalFetch = globalThis.fetch; + + // Set up Node.js environment simulation + global.process = { versions: { node: '18.0.0' } } as MockProcess; + + let dnsResolveCalled = false; + const mockDns = { + resolve4: async () => { + dnsResolveCalled = true; + return ['127.0.0.1']; // This would normally trigger SSRF protection + }, + resolve6: async () => [] + }; + + // Mock eval to return our mock DNS module + global.eval = (code: string) => { + if (code.includes('import("dns")')) { + return Promise.resolve({ promises: mockDns }); + } + return originalEval(code); + }; + + // Mock fetch to prevent real network requests + globalThis.fetch = async () => { + throw new Error('Network error - should not reach here'); + }; + + try { + const permissiveWf = new WebFinger({ + allow_private_addresses: true, + request_timeout: 100 + }); + + // Try to lookup a domain that would normally trigger DNS resolution + try { + await permissiveWf.lookup('test@malicious-domain.example'); + } catch (error) { + // Should fail with network error, not DNS validation error + expect(error.message).toBe('Network error - should not reach here'); + } + + // Verify DNS resolution was NOT called when allow_private_addresses is true + expect(dnsResolveCalled).toBe(false); + } finally { + // Restore original functions + global.eval = originalEval; + global.process = originalProcess; + globalThis.fetch = originalFetch; + } + }); + }); + + describe('Security Configuration', () => { + it('should have security features properly configured', () => { + const testWf = new WebFinger({ + allow_private_addresses: false, + request_timeout: 1000 + }); + + expect(testWf.config.allow_private_addresses).toBe(false); + expect(testWf).toBeDefined(); + }); + }); + }); + describe('Content-Type Warnings', () => { it('should debug log when server returns application/json', async () => { const originalFetch = globalThis.fetch; @@ -153,7 +521,10 @@ describe('WebFinger', () => { }; try { - const testWf = new WebFinger({ request_timeout: 1000 }); + const testWf = new WebFinger({ + request_timeout: 1000, + allow_private_addresses: true // Allow example.com for testing + }); await testWf.lookup('test@example.com'); expect(debugMessage).toContain('WebFinger: Server uses "application/json"'); @@ -188,7 +559,10 @@ describe('WebFinger', () => { }; try { - const testWf = new WebFinger({ request_timeout: 1000 }); + const testWf = new WebFinger({ + request_timeout: 1000, + allow_private_addresses: true // Allow example.com for testing + }); await testWf.lookup('test@example.com'); expect(warningMessage).toContain('WebFinger: Server returned unexpected content-type "text/html"'); @@ -224,7 +598,10 @@ describe('WebFinger', () => { }; try { - const testWf = new WebFinger({ request_timeout: 1000 }); + const testWf = new WebFinger({ + request_timeout: 1000, + allow_private_addresses: true // Allow example.com for testing + }); await testWf.lookup('test@example.com'); expect(warningCalled).toBe(false); @@ -259,7 +636,10 @@ describe('WebFinger', () => { }; try { - const testWf = new WebFinger({ request_timeout: 1000 }); + const testWf = new WebFinger({ + request_timeout: 1000, + allow_private_addresses: true // Allow example.com for testing + }); await testWf.lookup('test@example.com'); expect(debugMessage).toContain('WebFinger: Server uses "application/json"');
src/webfinger.ts+345 −23 modified@@ -47,6 +47,18 @@ const LINK_PROPERTIES = { // list of endpoints to try, fallback from beginning to end. const URIS = ['webfinger', 'host-meta', 'host-meta.json']; +// IPv4 address regex patterns - validate octets 0-255 +const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)'; // 0-255 +const IPV4_REGEX = new RegExp(`^(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}$`); +const IPV4_CAPTURE_REGEX = new RegExp(`^(${IPV4_OCTET})\\.(${IPV4_OCTET})\\.(${IPV4_OCTET})\\.(${IPV4_OCTET})$`); + +// Other validation regex patterns +const LOCALHOST_REGEX = /^localhost(?:\.localdomain)?(?::\d+)?$/; +const NUMERIC_PORT_REGEX = /^\d+$/; +const HOSTNAME_REGEX = /^[a-zA-Z0-9.-]+$/; +const LOCALHOST_127_REGEX = /^127\.(?:\d{1,3}\.){2}\d{1,3}$/; + + /** * Configuration options for WebFinger client */ @@ -58,7 +70,9 @@ export type WebFingerConfig = { /** Enable host-meta and host-meta.json fallback endpoints. */ uri_fallback: boolean, /** Request timeout in milliseconds. */ - request_timeout: number + request_timeout: number, + /** Allow private/internal addresses (DANGEROUS - only for development). */ + allow_private_addresses: boolean }; /** @@ -99,11 +113,37 @@ export type LinkObject = { } /** - * Custom error class for WebFinger-specific errors + * Custom error class for WebFinger-specific errors. + * + * This error is thrown for various WebFinger-related failures including: + * - Network errors (timeouts, DNS failures) + * - HTTP errors (404, 500, etc.) + * - Security violations (SSRF protection, invalid hosts) + * - Invalid response formats (malformed JSON, missing data) + * - Input validation failures (invalid addresses, formats) + * + * @example + * ```typescript + * try { + * await webfinger.lookup('user@localhost'); + * } catch (error) { + * if (error instanceof WebFingerError) { + * console.log('WebFinger error:', error.message); + * console.log('HTTP status:', error.status); // May be undefined + * } + * } + * ``` */ export class WebFingerError extends Error { + /** HTTP status code if the error originated from an HTTP response */ status?: number; - + + /** + * Creates a new WebFingerError instance. + * + * @param message - Error message describing what went wrong + * @param status - Optional HTTP status code if applicable + */ constructor(message: string, status?: number) { super(message); this.name = 'WebFingerError'; @@ -113,14 +153,14 @@ export class WebFingerError extends Error { /** * WebFinger client for discovering user information across domains. - * + * * @example * ```typescript * const webfinger = new WebFinger({ * webfist_fallback: true, * tls_only: true * }); - * + * * const result = await webfinger.lookup('user@domain.com'); * console.log(result.idx.properties.name); * ``` @@ -130,42 +170,77 @@ export default class WebFinger { /** * Creates a new WebFinger client instance. - * + * * @param cfg - Configuration options for the WebFinger client * @param cfg.tls_only - Use HTTPS only (default: true) * @param cfg.webfist_fallback - Enable WebFist fallback (default: false) - * @param cfg.uri_fallback - Enable host-meta fallback (default: false) + * @param cfg.uri_fallback - Enable host-meta fallback (default: false) * @param cfg.request_timeout - Request timeout in milliseconds (default: 10000) + * @param cfg.allow_private_addresses - Allow private/internal addresses (default: false, DANGEROUS) */ constructor(cfg: Partial<WebFingerConfig> = {}) { this.config = { tls_only: (typeof cfg.tls_only !== 'undefined') ? cfg.tls_only : true, webfist_fallback: (typeof cfg.webfist_fallback !== 'undefined') ? cfg.webfist_fallback : false, uri_fallback: (typeof cfg.uri_fallback !== 'undefined') ? cfg.uri_fallback : false, - request_timeout: (typeof cfg.request_timeout !== 'undefined') ? cfg.request_timeout : 10000 + request_timeout: (typeof cfg.request_timeout !== 'undefined') ? cfg.request_timeout : 10000, + allow_private_addresses: (typeof cfg.allow_private_addresses !== 'undefined') ? cfg.allow_private_addresses : false }; } // make an HTTP request and look for JRD response, fails if request fails - // or response not json. - private async fetchJRD(url: string): Promise<string> { + // or response not JSON. + private async fetchJRD(url: string, redirectCount: number = 0): Promise<string> { + // Prevent redirect loops (max 3 redirects) + if (redirectCount > 3) { + throw new WebFingerError('too many redirects'); + } + const response = await fetch(url, { headers: {'Accept': 'application/jrd+json, application/json'}, + redirect: 'manual' // Handle redirects manually for security validation }); + // Handle redirect responses with security validation + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + throw new WebFingerError('redirect without location header'); + } + + // Parse and validate redirect URL + let redirectUrl: URL; + try { + redirectUrl = new URL(location, url); // Resolve relative URLs + } catch { + throw new WebFingerError('invalid redirect URL'); + } + + // Security: Validate redirect destination host + const redirectHost = WebFinger.validateHost(redirectUrl.hostname + (redirectUrl.port ? ':' + redirectUrl.port : '')); + + // Security: Check if redirect target is private/internal address + if (!this.config.allow_private_addresses && WebFinger.isPrivateAddress(redirectHost)) { + throw new WebFingerError('redirect to private or internal address blocked'); + } + + // Follow the redirect + return this.fetchJRD(redirectUrl.toString(), redirectCount + 1); + } + if (response.status === 404) { throw new WebFingerError('resource not found', 404) - } else if (!response.ok) { // other HTTP status (redirects are handled transparently) + } else if (!response.ok) { throw new WebFingerError('error during request', response.status); } // Check Content-Type for RFC 7033 compliance (informational only) const contentType = response.headers.get('content-type') || ''; const lowerContentType = contentType.toLowerCase(); - + // Parse main media type (before semicolon for charset/boundary params) const mainType = lowerContentType.split(';')[0].trim(); - + if (mainType === 'application/jrd+json') { // Perfect - RFC 7033 compliant } else if (mainType === 'application/json') { @@ -197,9 +272,155 @@ export default class WebFinger { return true; }; + /** + * Checks if a host is localhost (used for protocol selection). + * + * @private + * @param host - The hostname to check + * @returns True if the host is a localhost variant + */ private static isLocalhost (host: string): boolean { - const local = /^localhost(\.localdomain)?(:[0-9]+)?$/; - return local.test(host); + return LOCALHOST_REGEX.test(host); + }; + + /** + * Comprehensive security check for private/internal addresses to prevent SSRF attacks. + * + * Blocks the following address ranges: + * - Localhost: localhost, 127.x.x.x, ::1, localhost.localdomain + * - Private IPv4: 10.x.x.x, 172.16-31.x.x, 192.168.x.x + * - Link-local: 169.254.x.x, fe80::/10 + * - Multicast: 224.x.x.x-239.x.x.x, ff00::/8 + * + * @private + * @param host - The hostname or IP address to check (may include port) + * @returns True if the address is private/internal and should be blocked + * @throws {WebFingerError} When host format is invalid + */ + private static isPrivateAddress(host: string): boolean { + // Handle IPv6 addresses in brackets + let cleanHost = host; + if (cleanHost.startsWith('[') && cleanHost.includes(']:')) { + // Extract IPv6 from [ipv6]:port format + cleanHost = cleanHost.substring(1, cleanHost.lastIndexOf(']:')); + } else if (cleanHost.startsWith('[') && cleanHost.endsWith(']')) { + // Extract IPv6 from [ipv6] format + cleanHost = cleanHost.substring(1, cleanHost.length - 1); + } else if (cleanHost.includes(':')) { + // Check if this is an IPv6 address (contains multiple colons) or IPv4/hostname with port + const colonCount = (cleanHost.match(/:/g) || []).length; + if (colonCount === 1) { + // Single colon - check if it's hostname:port or ipv4:port (not IPv6) + const parts = cleanHost.split(':'); + const hostPart = parts[0]; + const portPart = parts[1]; + + // Validate that port is numeric if present + if (portPart && !NUMERIC_PORT_REGEX.test(portPart)) { + // Invalid port, treat as invalid host + throw new WebFingerError('invalid host format'); + } + + // Check if the host part looks like IPv4 or hostname (not IPv6) + if (hostPart.match(IPV4_REGEX) || // IPv4 pattern + hostPart.match(HOSTNAME_REGEX)) { // Hostname pattern + cleanHost = hostPart; + } + // Otherwise keep as is (might be short IPv6 like ::1) + } + // Otherwise it's IPv6, keep as is + } + + // Check for localhost variants + if (cleanHost === 'localhost' || + cleanHost === '127.0.0.1' || + cleanHost.match(LOCALHOST_127_REGEX) || + cleanHost === '::1' || + cleanHost === 'localhost.localdomain') { + return true; + } + + // Check for private IPv4 ranges (only if it looks like IPv4) + const ipv4Match = cleanHost.match(IPV4_CAPTURE_REGEX); + if (ipv4Match) { + const [, aStr, bStr, cStr, dStr] = ipv4Match; + const a = Number(aStr); + const b = Number(bStr); + const c = Number(cStr); + const d = Number(dStr); + + // Note: Regex already validates 0-255 range, but we still check for NaN as defense-in-depth + if (isNaN(a) || isNaN(b) || isNaN(c) || isNaN(d)) { + // This should not happen with our regex, but treat as potentially dangerous + return true; + } + + // 10.0.0.0/8 (Private) + if (a === 10) return true; + + // 172.16.0.0/12 (Private) + if (a === 172 && b >= 16 && b <= 31) return true; + + // 192.168.0.0/16 (Private) + if (a === 192 && b === 168) return true; + + // 169.254.0.0/16 (Link-local) + if (a === 169 && b === 254) return true; + + // 224.0.0.0/4 (Multicast) + if (a >= 224 && a <= 239) return true; + + // 240.0.0.0/4 (Reserved) + if (a >= 240) return true; + } + + // Check for private IPv6 ranges (only if cleanHost still contains colons after processing above) + if (cleanHost.includes(':')) { + // IPv6 private ranges - verify this is actually IPv6, not hostname:port that wasn't processed + const colonCount = (cleanHost.match(/:/g) || []).length; + if (colonCount > 1 || // Multiple colons = definitely IPv6 + (colonCount === 1 && !cleanHost.match(/^[a-zA-Z0-9.-]+:\d+$/))) { // Single colon but not hostname:port format + if (cleanHost.match(/^(fc|fd)[0-9a-f]{2}:/i) || // Unique local addresses + cleanHost.match(/^fe80:/i) || // Link-local + cleanHost.match(/^ff[0-9a-f]{2}:/i)) { // Multicast + return true; + } + } + } + + return false; + }; + + /** + * Validates and sanitizes host to prevent path injection attacks. + * + * Removes path components and validates hostname format to prevent: + * - Directory traversal attacks via path injection + * - Query parameter injection + * - Fragment injection + * - Invalid characters in hostnames + * + * @private + * @param host - Raw host string that may contain path components + * @returns Cleaned hostname with only valid hostname and port + * @throws {WebFingerError} When host format is invalid or contains dangerous characters + */ + private static validateHost(host: string): string { + // Remove any path components - only keep hostname and port + const hostParts = host.split('/'); + const cleanHost = hostParts[0]; + + // Validate hostname format + if (!cleanHost || cleanHost.length === 0) { + throw new WebFingerError('invalid host format'); + } + + // Check for invalid characters that could indicate injection + if (cleanHost.includes('?') || cleanHost.includes('#') || cleanHost.includes(' ')) { + throw new WebFingerError('invalid characters in host'); + } + + return cleanHost; }; // processes JRD object as if it's a WebFinger response object @@ -259,12 +480,88 @@ export default class WebFinger { }; /** - * Performs a WebFinger lookup for the given address. - * + * Resolves a hostname to IP addresses and validates they are not private addresses. + * + * This prevents DNS-based SSRF attacks where public domains resolve to private + * IP addresses (e.g., yoogle.com -> 127.0.0.1). Only performs DNS resolution + * in Node.js/Bun environments where the dns module is available. + * + * @private + * @param hostname - The hostname to resolve (without port) + * @returns Promise<void> - Resolves if all IPs are public, throws if any private IPs found + * @throws {WebFingerError} When hostname resolves to private/internal addresses + */ + + private async validateDNSResolution(hostname: string): Promise<void> { + // Skip DNS resolution for IP addresses (already validated by isPrivateAddress) + if (hostname.match(IPV4_REGEX) || + hostname.includes(':') || // IPv6 addresses contain colons + hostname === 'localhost') { + return; + } + + // Perform DNS resolution if in Node.js/Bun environment + const isNodeJS = typeof process !== 'undefined' && process.versions?.node; + + if (isNodeJS) { + try { + // Dynamic import for Node.js dns module (not available in browsers) + // Use eval to prevent bundlers from trying to resolve this import + const dnsImport = eval('import("dns")'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dns = await dnsImport.then((m: any) => m.promises).catch(() => null); + + if (dns) { + try { + // Resolve both A and AAAA records + const [ipv4Results, ipv6Results] = await Promise.allSettled([ + dns.resolve4(hostname).catch(() => []), + dns.resolve6(hostname).catch(() => []) + ]); + + const ipv4Addresses = ipv4Results.status === 'fulfilled' ? ipv4Results.value : []; + const ipv6Addresses = ipv6Results.status === 'fulfilled' ? ipv6Results.value : []; + + // Check all resolved IP addresses + for (const ip of [...ipv4Addresses, ...ipv6Addresses]) { + if (WebFinger.isPrivateAddress(ip)) { + throw new WebFingerError(`hostname ${hostname} resolves to private address ${ip}`); + } + } + } catch (error) { + if (error instanceof WebFingerError) { + throw error; + } + // DNS resolution failed - this might be a legitimate DNS error + // We'll allow it to proceed as blocking all DNS failures would be too restrictive + } + } + } catch (outerError) { + // Re-throw WebFingerErrors (security errors should not be swallowed) + if (outerError instanceof WebFingerError) { + throw outerError; + } + // DNS module not available or import failed. + // Already have blacklist protection above + } + } + } + + /** + * Performs a WebFinger lookup for the given address with comprehensive SSRF protection. + * + * This method includes comprehensive security measures: + * - Blocks private/internal IP addresses by default + * - Validates host format to prevent path injection + * - Validates DNS resolution to block domains that resolve to private IPs + * - Validates redirect destinations to prevent redirect-based SSRF attacks + * - Follows ActivityPub security guidelines + * - Limits redirect chains to prevent redirect loops + * * @param address - Email-like address (user@domain.com) or full URI to look up * @returns Promise resolving to WebFinger result with indexed links and properties - * @throws {WebFingerError} When lookup fails or address is invalid - * + * @throws {WebFingerError} When lookup fails, address is invalid, or SSRF protection blocks the request + * * @example * ```typescript * try { @@ -275,12 +572,22 @@ export default class WebFinger { * console.error('Lookup failed:', error.message); * } * ``` + * + * @example Security - Blocked addresses and redirects + * ```typescript + * // These will throw WebFingerError due to SSRF protection: + * await webfinger.lookup('user@localhost'); // Direct access blocked + * await webfinger.lookup('user@127.0.0.1'); // Direct access blocked + * await webfinger.lookup('user@192.168.1.1'); // Direct access blocked + * // Domains that resolve to private IPs are blocked via DNS resolution (Node.js/Bun) + * // Redirects to private addresses are also blocked automatically + * ``` */ async lookup(address: string): Promise<WebFingerResult> { if (!address) { throw new WebFingerError('address is required'); } - + let host = ''; if (address.indexOf('://') > -1) { // other uri format @@ -297,10 +604,25 @@ export default class WebFinger { } host = parts[1]; } - + if (!host) { throw new WebFingerError('could not determine host from address'); } + + // Security: Validate and sanitize the host + host = WebFinger.validateHost(host); + + // Security: Check for private/internal addresses to prevent SSRF + if (!this.config.allow_private_addresses && WebFinger.isPrivateAddress(host)) { + throw new WebFingerError('private or internal addresses are not allowed'); + } + + // Security: Additional DNS resolution validation for domains that might resolve to private IPs + if (!this.config.allow_private_addresses) { + // Extract hostname without port for DNS validation + const hostname = host.includes(':') ? host.split(':')[0] : host; + await this.validateDNSResolution(hostname); + } let uri_index = 0; // track which URIS we've tried already let protocol = 'https'; // we use https by default @@ -365,13 +687,13 @@ export default class WebFinger { /** * Looks up a specific link relation for the given address. - * + * * @param address - Email-like address (user@domain.com) or full URI * @param rel - Link relation type (e.g., 'avatar', 'blog', 'remotestorage') * @returns Promise resolving to the first matching link object * @throws {WebFingerError} When lookup fails * @throws {Error} When no links found for the specified relation - * + * * @example * ```typescript * try {
c01379e67112Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-8xq3-w9fx-74rvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-54590ghsaADVISORY
- github.com/silverbucket/webfinger.js/commit/b5f2f2c957297d25f4d76072963fccaee2e3095anvdWEB
- github.com/silverbucket/webfinger.js/releases/tag/v2.8.1nvdWEB
- github.com/silverbucket/webfinger.js/security/advisories/GHSA-8xq3-w9fx-74rvnvdWEB
News mentions
0No linked articles in our index yet.