mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
153 lines
5 KiB
TypeScript
153 lines
5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createServer, type Server } from 'node:http';
|
|
|
|
const mockLookup = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('node:dns/promises', () => ({
|
|
lookup: mockLookup,
|
|
}));
|
|
|
|
import { safeFetch, validateWebhookUrl } from '../utils/url-validation.js';
|
|
|
|
async function listenLocalServer(
|
|
handler: Parameters<typeof createServer>[0]
|
|
): Promise<{ server: Server; port: number }> {
|
|
const server = createServer(handler);
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Test server did not bind to a TCP port');
|
|
}
|
|
return { server, port: address.port };
|
|
}
|
|
|
|
async function closeServer(server: Server): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
}
|
|
|
|
describe('url validation', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
mockLookup.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('blocks localhost webhook URLs before fetch', async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
|
|
expect(validateWebhookUrl('https://localhost/hook').valid).toBe(false);
|
|
await expect(safeFetch('https://127.0.0.1/hook')).resolves.toBeNull();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks hostnames that resolve to private addresses', async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
mockLookup.mockResolvedValue([{ address: '10.0.0.12', family: 4 }]);
|
|
|
|
await expect(safeFetch('https://hooks.example.test/hook')).resolves.toBeNull();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows loopback addresses when localhost destinations are explicitly allowed', async () => {
|
|
const { server, port } = await listenLocalServer((_req, res) => {
|
|
res.writeHead(202, { 'content-type': 'text/plain' });
|
|
res.end('accepted');
|
|
});
|
|
|
|
try {
|
|
const response = await safeFetch(
|
|
`http://127.0.0.1:${port}/hook`,
|
|
{ method: 'POST', body: 'payload' },
|
|
{ allowHttp: true, allowLocalhost: true }
|
|
);
|
|
|
|
expect(response?.status).toBe(202);
|
|
await expect(response?.text()).resolves.toBe('accepted');
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
it.each(['10.0.0.1', '172.16.0.1', '192.168.0.1'])(
|
|
'does not treat allowLocalhost as private-network approval for %s',
|
|
async (address) => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
const url = `http://${address}/hook`;
|
|
const validationOptions = { allowHttp: true, allowLocalhost: true, logFailures: false };
|
|
|
|
expect(validateWebhookUrl(url, validationOptions).valid).toBe(false);
|
|
await expect(safeFetch(url, undefined, validationOptions)).resolves.toBeNull();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
it('does not treat allowLocalhost as DNS approval for resolved private addresses', async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
mockLookup.mockResolvedValue([{ address: '192.168.1.20', family: 4 }]);
|
|
|
|
await expect(
|
|
safeFetch('http://hooks.example.test/hook', undefined, {
|
|
allowHttp: true,
|
|
allowLocalhost: true,
|
|
logFailures: false,
|
|
})
|
|
).resolves.toBeNull();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('pins outbound fetches to the validated DNS answer', async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
const { server, port } = await listenLocalServer((req, res) => {
|
|
expect(req.headers.host).toBe(`hooks.example.test:${port}`);
|
|
res.writeHead(202, { 'content-type': 'text/plain' });
|
|
res.end('accepted');
|
|
});
|
|
mockLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
|
|
|
try {
|
|
const response = await safeFetch(
|
|
`http://hooks.example.test:${port}/hook`,
|
|
{ method: 'POST', body: 'payload', redirect: 'follow' },
|
|
{ allowHttp: true, allowPrivateIp: true }
|
|
);
|
|
|
|
expect(response?.status).toBe(202);
|
|
await expect(response?.text()).resolves.toBe('accepted');
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
|
|
it('does not follow redirects for allowed outbound fetches', async () => {
|
|
const { server, port } = await listenLocalServer((_req, res) => {
|
|
res.writeHead(302, { location: 'http://127.0.0.1/admin' });
|
|
res.end();
|
|
});
|
|
mockLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
|
|
|
try {
|
|
const response = await safeFetch(`http://hooks.example.test:${port}/hook`, undefined, {
|
|
allowHttp: true,
|
|
allowPrivateIp: true,
|
|
});
|
|
|
|
expect(response?.status).toBe(302);
|
|
expect(response?.headers.get('location')).toBe('http://127.0.0.1/admin');
|
|
} finally {
|
|
await closeServer(server);
|
|
}
|
|
});
|
|
});
|