File size: 6,344 Bytes
a0ebf39 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | 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',
);
});
});
|