import { BadRequestException } from '@nestjs/common'; import { assertPublicWebhookUrl } from './webhook-url-guard'; function lookupResolving(...addresses: string[]) { return () => Promise.resolve(addresses.map((address) => ({ address, family: 4 }))); } function lookupFailing() { return () => Promise.reject(new Error('ENOTFOUND')); } describe('assertPublicWebhookUrl', () => { it('accepts a hostname that resolves only to public addresses', async () => { await expect( assertPublicWebhookUrl( 'https://example.com/hook', lookupResolving('93.184.216.34'), ), ).resolves.toBeUndefined(); }); it('rejects an invalid URL', async () => { await expect(assertPublicWebhookUrl('not-a-url')).rejects.toBeInstanceOf( BadRequestException, ); }); it('rejects non-http(s) schemes', async () => { await expect( assertPublicWebhookUrl('ftp://example.com/hook'), ).rejects.toBeInstanceOf(BadRequestException); await expect( assertPublicWebhookUrl('file:///etc/passwd'), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects localhost by hostname', async () => { await expect( assertPublicWebhookUrl('http://localhost/hook'), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects IPv4 loopback and its shorthand/decimal/hex/octal encodings', async () => { for (const url of [ 'http://127.0.0.1/hook', 'http://127.1/hook', 'http://0x7f000001/hook', 'http://2130706433/hook', 'http://017700000001/hook', ]) { await expect(assertPublicWebhookUrl(url)).rejects.toBeInstanceOf( BadRequestException, ); } }); it('rejects RFC 1918 private ranges', async () => { for (const url of [ 'http://10.0.0.5/hook', 'http://172.16.0.5/hook', 'http://172.31.255.255/hook', 'http://192.168.1.1/hook', ]) { await expect(assertPublicWebhookUrl(url)).rejects.toBeInstanceOf( BadRequestException, ); } }); it('rejects link-local addresses including the cloud metadata endpoint', async () => { await expect( assertPublicWebhookUrl('http://169.254.169.254/latest/meta-data/'), ).rejects.toBeInstanceOf(BadRequestException); }); it('does not reject a public IPv4 literal outside reserved ranges', async () => { await expect( assertPublicWebhookUrl('http://8.8.8.8/hook'), ).resolves.toBeUndefined(); }); it('rejects IPv6 loopback and link-local addresses', async () => { await expect( assertPublicWebhookUrl('http://[::1]/hook'), ).rejects.toBeInstanceOf(BadRequestException); await expect( assertPublicWebhookUrl('http://[fe80::1]/hook'), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a hostname that resolves to a private address (DNS-based bypass)', async () => { await expect( assertPublicWebhookUrl( 'https://attacker.example/hook', lookupResolving('10.0.0.5'), ), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a hostname that resolves to the cloud metadata address', async () => { await expect( assertPublicWebhookUrl( 'https://attacker.example/hook', lookupResolving('169.254.169.254'), ), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a hostname where any resolved address is private, even if others are public', async () => { await expect( assertPublicWebhookUrl( 'https://attacker.example/hook', lookupResolving('93.184.216.34', '10.0.0.5'), ), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a hostname that fails to resolve', async () => { await expect( assertPublicWebhookUrl( 'https://does-not-exist.invalid/hook', lookupFailing(), ), ).rejects.toBeInstanceOf(BadRequestException); }); });