| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- import { beforeEach, describe, expect, it, vi } from 'vitest';
- const { lookupMock } = vi.hoisted(() => ({
- lookupMock: vi.fn(),
- }));
- vi.mock('node:dns', () => ({
- promises: {
- lookup: lookupMock,
- },
- }));
- describe('validateUrlForSSRF', () => {
- beforeEach(() => {
- vi.resetModules();
- lookupMock.mockReset();
- });
- it('allows a public hostname when DNS resolves to a public IP', async () => {
- lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('https://api.openai.com')).resolves.toBeNull();
- expect(lookupMock).toHaveBeenCalledWith('api.openai.com', { all: true, verbatim: true });
- });
- it('allows a public IP literal without DNS lookup', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('https://8.8.8.8')).resolves.toBeNull();
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('allows a public IPv6 literal without DNS lookup', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('https://[2606:4700:4700::1111]')).resolves.toBeNull();
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('rejects invalid URLs and non-http protocols', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('not-a-url')).resolves.toBe('Invalid URL');
- await expect(validateUrlForSSRF('ftp://example.com')).resolves.toBe(
- 'Only HTTP(S) URLs are allowed',
- );
- await expect(validateUrlForSSRF('file:///etc/passwd')).resolves.toBe(
- 'Only HTTP(S) URLs are allowed',
- );
- await expect(validateUrlForSSRF('javascript:alert(1)')).resolves.toBe(
- 'Only HTTP(S) URLs are allowed',
- );
- });
- it('rejects blocked hostnames immediately', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('http://localhost')).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- await expect(validateUrlForSSRF('http://printer.local')).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('rejects private IPv4 literals', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- const urls = [
- 'http://127.0.0.1',
- 'http://10.0.0.42',
- 'http://172.16.5.4',
- 'http://172.31.255.255',
- 'http://192.168.1.10',
- 'http://169.254.169.254',
- 'http://0.0.0.0',
- ];
- for (const url of urls) {
- await expect(validateUrlForSSRF(url)).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- }
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('rejects private IPv6 literals and mapped loopback addresses', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- const urls = [
- 'http://[::1]',
- 'http://[fd00::1234]',
- 'http://[fe80::1]',
- 'http://[fec0::1]',
- 'http://[::ffff:127.0.0.1]',
- ];
- for (const url of urls) {
- await expect(validateUrlForSSRF(url)).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- }
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('rejects 6to4 tunnel addresses embedding private IPv4', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- // 2002:7f00:0001:: embeds 127.0.0.1
- await expect(validateUrlForSSRF('http://[2002:7f00:0001::]')).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- // 2002:0a00:0001:: embeds 10.0.0.1
- await expect(validateUrlForSSRF('http://[2002:0a00:0001::]')).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('allows 6to4 tunnel addresses embedding public IPv4', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- // 2002:0808:0808:: embeds 8.8.8.8
- await expect(validateUrlForSSRF('http://[2002:0808:0808::]')).resolves.toBeNull();
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('rejects Teredo tunnel addresses embedding private IPv4', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- // Client IPv4 127.0.0.1 XOR 0xFFFFFFFF = 0x80FFFFFE → hextets 80ff:fffe
- await expect(
- validateUrlForSSRF('http://[2001:0000:4136:e378:8000:63bf:80ff:fffe]'),
- ).resolves.toBe('Local/private network URLs are not allowed');
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('allows Teredo tunnel addresses embedding public IPv4', async () => {
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- // Client IPv4 8.8.8.8 XOR 0xFFFFFFFF = 0xF7F7F7F7 → hextets f7f7:f7f7
- await expect(
- validateUrlForSSRF('http://[2001:0000:4136:e378:8000:63bf:f7f7:f7f7]'),
- ).resolves.toBeNull();
- expect(lookupMock).not.toHaveBeenCalled();
- });
- it('rejects hostnames that resolve to a private IP', async () => {
- lookupMock.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('https://attacker.com')).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- });
- it('rejects hostnames when any DNS answer is private', async () => {
- lookupMock.mockResolvedValue([
- { address: '93.184.216.34', family: 4 },
- { address: '192.168.1.10', family: 4 },
- ]);
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('https://mixed.example')).resolves.toBe(
- 'Local/private network URLs are not allowed',
- );
- });
- it('fails closed when DNS lookup errors', async () => {
- lookupMock.mockRejectedValue(new Error('ENOTFOUND'));
- const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
- await expect(validateUrlForSSRF('https://missing.example')).resolves.toBe(
- 'Unable to verify hostname safety',
- );
- });
- });
|