fix: validate URLs before outbound fetch

This commit is contained in:
Linus Rath
2026-04-27 22:23:39 +02:00
parent e9b3eacbb7
commit 3043639d2d
6 changed files with 379 additions and 90 deletions
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('isPublicHttpUrl', () => {
beforeEach(() => {
lookup.mockReset();
});
afterEach(() => {
vi.resetModules();
});
async function load() {
const mod = await import('@/lib/security/url-guard');
return mod.isPublicHttpUrl;
}
it('accepts public https URLs whose DNS resolves to a public address', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://example.com/jmap')).toBe(true);
});
it('rejects malformed URLs', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('not a url')).toBe(false);
expect(await isPublicHttpUrl('')).toBe(false);
});
it('rejects non-http(s) protocols', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('file:///etc/passwd')).toBe(false);
expect(await isPublicHttpUrl('gopher://example.com/')).toBe(false);
expect(await isPublicHttpUrl('javascript:alert(1)')).toBe(false);
});
it('rejects URLs with embedded credentials', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://user:pass@example.com/')).toBe(false);
expect(await isPublicHttpUrl('https://user@example.com/')).toBe(false);
});
it('rejects loopback hostnames without DNS', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://localhost/')).toBe(false);
expect(await isPublicHttpUrl('http://service.localhost/')).toBe(false);
expect(await isPublicHttpUrl('http://server.local/')).toBe(false);
expect(await isPublicHttpUrl('http://kube.internal/api')).toBe(false);
expect(await isPublicHttpUrl('http://1.0.0.127.in-addr.arpa/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects literal IPv4 loopback and RFC-1918 ranges', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://127.0.0.1/')).toBe(false);
expect(await isPublicHttpUrl('http://10.0.0.5/')).toBe(false);
expect(await isPublicHttpUrl('http://10.255.255.255/')).toBe(false);
expect(await isPublicHttpUrl('http://172.16.0.1/')).toBe(false);
expect(await isPublicHttpUrl('http://172.31.255.254/')).toBe(false);
expect(await isPublicHttpUrl('http://192.168.1.1/')).toBe(false);
expect(await isPublicHttpUrl('http://0.0.0.0/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects literal AWS / GCP / Azure metadata IP', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false);
expect(await isPublicHttpUrl('http://169.254.0.1/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects IPv6 loopback, ULA, and link-local literals', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://[::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[::]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fc00::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fd12:3456::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fe80::1]/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects when DNS resolves to a private address (rebinding)', async () => {
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://evil.example.com/')).toBe(false);
});
it('rejects when any resolved address is private (mixed)', async () => {
lookup.mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.1', family: 4 },
]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://mixed.example.com/')).toBe(false);
});
it('rejects when DNS resolves to IPv6 loopback', async () => {
lookup.mockResolvedValue([{ address: '::1', family: 6 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://evil6.example.com/')).toBe(false);
});
it('rejects when DNS lookup throws', async () => {
lookup.mockRejectedValue(new Error('ENOTFOUND'));
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://nonexistent.example.com/')).toBe(false);
});
it('rejects when DNS returns no records', async () => {
lookup.mockResolvedValue([]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://empty.example.com/')).toBe(false);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('verifyJmapAuth SSRF protection', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
lookup.mockReset();
vi.resetModules();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
async function load() {
const mod = await import('@/lib/auth/verify-jmap-auth');
return mod;
}
it('rejects loopback literal without issuing fetch', async () => {
const { verifyJmapAuth, JmapAuthVerificationError } = await load();
await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf(
JmapAuthVerificationError,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects AWS IMDS endpoint without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('http://169.254.169.254', 'Bearer x'),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects RFC-1918 literals without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) {
await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 });
}
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects localhost hostname without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects IPv6 loopback literal without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects file:// URLs', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('refuses to follow a redirect to a private address', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('refuses to follow a redirect to AWS IMDS', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
}),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('accepts a public host that returns a valid JMAP session', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe(
'https://example.com',
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://example.com/.well-known/jmap',
expect.objectContaining({ redirect: 'manual' }),
);
});
it('rejects an invalid Authorization header before any fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
});