mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
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]);
|
|
});
|
|
});
|