mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
import { safeFetchAdminConfigured, SsrfBlockedError } from '../../../src/utils/ssrfGuard';
|
|
|
|
import dns from 'dns/promises';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import type { AddressInfo } from 'node:net';
|
|
import os from 'node:os';
|
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest';
|
|
|
|
// Only the resolver is stubbed. undici and net are the real thing, because the
|
|
// point of these cases is what the socket does with the list it is handed.
|
|
vi.mock('dns/promises', () => ({
|
|
default: { lookup: vi.fn() },
|
|
lookup: vi.fn(),
|
|
}));
|
|
|
|
const mockLookup = vi.mocked(dns.lookup);
|
|
|
|
// Every address a socket dials, read off the socket itself rather than the
|
|
// resolver: Node emits `connectionAttempt` once for each address it tries.
|
|
const attempted: string[] = [];
|
|
const realConnect = net.Socket.prototype.connect;
|
|
function trackAttempts(this: net.Socket, ...args: unknown[]) {
|
|
this.on('connectionAttempt', (ip: string) => attempted.push(ip));
|
|
return (realConnect as (...a: unknown[]) => net.Socket).apply(this, args);
|
|
}
|
|
|
|
// RFC 6666 discard prefix and TEST-NET-1: routed nowhere on any sane machine,
|
|
// so a connect attempt either fails at once or sits until the family timeout.
|
|
const DEAD_V6 = '100::1';
|
|
const DEAD_V4 = '192.0.2.1';
|
|
|
|
let v4: http.Server;
|
|
let v4Port: number;
|
|
let v6: http.Server | null = null;
|
|
let v6Port = 0;
|
|
|
|
const listen = (server: http.Server, host: string) =>
|
|
new Promise<number>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, host, () => resolve((server.address() as AddressInfo).port));
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
net.Socket.prototype.connect = trackAttempts as typeof realConnect;
|
|
v4 = http.createServer((_req, res) => res.end('v4'));
|
|
v4Port = await listen(v4, '127.0.0.1');
|
|
const six = http.createServer((_req, res) => res.end('v6'));
|
|
try {
|
|
v6Port = await listen(six, '::1');
|
|
v6 = six;
|
|
} catch {
|
|
six.close();
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
net.Socket.prototype.connect = realConnect;
|
|
await new Promise((r) => v4.close(r));
|
|
if (v6) await new Promise((r) => v6!.close(r));
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockLookup.mockReset();
|
|
attempted.length = 0;
|
|
});
|
|
|
|
describe('a dual-stack name where one family does not answer', () => {
|
|
it('SEC-DUAL-101: the AAAA first in the answer no longer pins the connection to an unreachable IPv6', async () => {
|
|
mockLookup.mockResolvedValue([
|
|
{ address: DEAD_V6, family: 6 },
|
|
{ address: '127.0.0.1', family: 4 },
|
|
] as never);
|
|
|
|
const started = Date.now();
|
|
const res = await safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe('v4');
|
|
// Well inside what a single dead attempt would have cost.
|
|
expect(Date.now() - started).toBeLessThan(3000);
|
|
});
|
|
|
|
it('SEC-DUAL-102: a dead IPv4 ahead of a live IPv6 still connects, the socket moves on by itself', async (ctx) => {
|
|
if (!v6) return ctx.skip();
|
|
mockLookup.mockResolvedValue([
|
|
{ address: DEAD_V4, family: 4 },
|
|
{ address: '::1', family: 6 },
|
|
] as never);
|
|
|
|
const started = Date.now();
|
|
const res = await safeFetchAdminConfigured(`http://idp.example:${v6Port}/`, { signal: AbortSignal.timeout(8000) });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe('v6');
|
|
// One attempt timeout (250 ms by default) plus the real connect.
|
|
expect(Date.now() - started).toBeLessThan(net.getDefaultAutoSelectFamilyAttemptTimeout() * 8 + 1000);
|
|
});
|
|
|
|
it('SEC-DUAL-103: the socket never gets an address the guard did not check', async () => {
|
|
// The resolver answers the checked list once; if the socket re-resolved the
|
|
// name it would be handed this second, unchecked answer and connect there.
|
|
mockLookup
|
|
.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as never)
|
|
.mockResolvedValue([{ address: DEAD_V4, family: 4 }] as never);
|
|
|
|
const res = await safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockLookup).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('a name with link-local records next to a reachable address (#2506)', () => {
|
|
it('SEC-DUAL-104: an fe80:: record beside the IdP address no longer refuses the name', async () => {
|
|
// Loopback stands in for the LAN address, being the one address reachable
|
|
// here; the admin lane treats the two alike.
|
|
mockLookup.mockResolvedValue([
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: '127.0.0.1', family: 4 },
|
|
] as never);
|
|
|
|
const res = await safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe('v4');
|
|
expect(attempted).toEqual(['127.0.0.1']);
|
|
});
|
|
|
|
it('SEC-DUAL-105: the socket never dials a link-local or metadata address, not even one listed first', async () => {
|
|
mockLookup.mockResolvedValue([
|
|
{ address: '169.254.169.254', family: 4 },
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: '::ffff:169.254.169.254', family: 6 },
|
|
{ address: '::169.254.169.254', family: 6 },
|
|
{ address: 'fd00:ec2::254', family: 6 },
|
|
{ address: '127.0.0.1', family: 4 },
|
|
] as never);
|
|
|
|
const res = await safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(attempted).toEqual(['127.0.0.1']);
|
|
});
|
|
|
|
it('SEC-DUAL-106: with nothing else to offer the name is refused and no socket is opened', async () => {
|
|
mockLookup.mockResolvedValue([
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: '169.254.169.254', family: 4 },
|
|
] as never);
|
|
|
|
await expect(
|
|
safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) }),
|
|
).rejects.toThrow(SsrfBlockedError);
|
|
expect(attempted).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('the strict guard, ALLOW_INTERNAL_NETWORK on, with metadata records in the answer (#2506)', () => {
|
|
// The strict guard refuses loopback, so the reachable address here is one of
|
|
// this machine's own interface addresses, with a server listening on it.
|
|
const lanIp = Object.values(os.networkInterfaces())
|
|
.flat()
|
|
.find((nic) => nic && nic.family === 'IPv4' && !nic.internal && !nic.address.startsWith('169.254.'))?.address;
|
|
let lan: http.Server | null = null;
|
|
let lanPort = 0;
|
|
|
|
beforeAll(async () => {
|
|
if (!lanIp) return;
|
|
const server = http.createServer((_req, res) => res.end('lan'));
|
|
try {
|
|
lanPort = await listen(server, lanIp);
|
|
lan = server;
|
|
} catch {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (lan) await new Promise((r) => lan!.close(r));
|
|
});
|
|
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
// The flag is read when the guard loads, so each case loads a fresh copy.
|
|
async function strictGuard() {
|
|
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'true');
|
|
vi.resetModules();
|
|
const guard = await import('../../../src/utils/ssrfGuard');
|
|
const lookup = vi.mocked((await import('dns/promises')).default.lookup);
|
|
return { guard, lookup };
|
|
}
|
|
|
|
it('SEC-DUAL-107: a metadata record next to an fe80:: one is refused and no socket is opened', async () => {
|
|
for (const answer of [
|
|
[
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: 'fd00:ec2::254', family: 6 },
|
|
],
|
|
[
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: '100.100.100.200', family: 4 },
|
|
],
|
|
[{ address: '::169.254.169.254', family: 6 }],
|
|
]) {
|
|
const { guard, lookup } = await strictGuard();
|
|
lookup.mockResolvedValue(answer as never);
|
|
|
|
await expect(
|
|
guard.safeFetch(`http://photos.example:${v4Port}/`, { signal: AbortSignal.timeout(3000) }),
|
|
).rejects.toThrow(guard.SsrfBlockedError);
|
|
}
|
|
expect(attempted).toEqual([]);
|
|
});
|
|
|
|
it('SEC-DUAL-108: the socket dials the LAN address and never the metadata ones listed ahead of it', async (ctx) => {
|
|
if (!lan || !lanIp) return ctx.skip();
|
|
const { guard, lookup } = await strictGuard();
|
|
lookup.mockResolvedValue([
|
|
{ address: 'fd00:ec2::254', family: 6 },
|
|
{ address: '100.100.100.200', family: 4 },
|
|
{ address: '100.100.100.100', family: 4 },
|
|
{ address: '::169.254.169.254', family: 6 },
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: lanIp, family: 4 },
|
|
] as never);
|
|
|
|
const res = await guard.safeFetch(`http://nas.example:${lanPort}/`, { signal: AbortSignal.timeout(8000) });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe('lan');
|
|
expect(attempted).toEqual([lanIp]);
|
|
});
|
|
});
|