Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

207 lines
7.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { lookupMock } = vi.hoisted(() => ({
lookupMock: vi.fn(),
}));
vi.mock('node:dns', () => ({
promises: {
lookup: lookupMock,
},
}));
const PRIVATE_NETWORK_BLOCK_MESSAGE =
'Local/private network URLs are not allowed. If this is a self-hosted deployment or internal gateway (including split-horizon DNS), set ALLOW_LOCAL_NETWORKS=true to allow local network targets.';
const ALLOW_LOCAL_NETWORKS_GUIDANCE = 'ALLOW_LOCAL_NETWORKS=true';
const originalAllowLocalNetworks = process.env.ALLOW_LOCAL_NETWORKS;
describe('validateUrlForSSRF', () => {
beforeEach(() => {
vi.resetModules();
lookupMock.mockReset();
delete process.env.ALLOW_LOCAL_NETWORKS;
});
afterEach(() => {
if (originalAllowLocalNetworks === undefined) {
delete process.env.ALLOW_LOCAL_NETWORKS;
} else {
process.env.ALLOW_LOCAL_NETWORKS = originalAllowLocalNetworks;
}
});
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');
const localhostResult = await validateUrlForSSRF('http://localhost');
expect(localhostResult).toBe(PRIVATE_NETWORK_BLOCK_MESSAGE);
expect(localhostResult).toContain(ALLOW_LOCAL_NETWORKS_GUIDANCE);
await expect(validateUrlForSSRF('http://printer.local')).resolves.toBe(
PRIVATE_NETWORK_BLOCK_MESSAGE,
);
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(PRIVATE_NETWORK_BLOCK_MESSAGE);
}
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(PRIVATE_NETWORK_BLOCK_MESSAGE);
}
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(
PRIVATE_NETWORK_BLOCK_MESSAGE,
);
// 2002:0a00:0001:: embeds 10.0.0.1
await expect(validateUrlForSSRF('http://[2002:0a00:0001::]')).resolves.toBe(
PRIVATE_NETWORK_BLOCK_MESSAGE,
);
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(PRIVATE_NETWORK_BLOCK_MESSAGE);
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');
const result = await validateUrlForSSRF('https://attacker.com');
expect(result).toBe(PRIVATE_NETWORK_BLOCK_MESSAGE);
expect(result).toContain(ALLOW_LOCAL_NETWORKS_GUIDANCE);
});
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(
PRIVATE_NETWORK_BLOCK_MESSAGE,
);
});
it('allows local network targets when ALLOW_LOCAL_NETWORKS=true', async () => {
process.env.ALLOW_LOCAL_NETWORKS = 'true';
lookupMock.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
await expect(validateUrlForSSRF('http://192.168.1.10')).resolves.toBeNull();
await expect(validateUrlForSSRF('https://internal.example')).resolves.toBeNull();
expect(lookupMock).not.toHaveBeenCalled();
});
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',
);
});
});