mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
0887f251ce
Since 4.3.0 the guard resolves a name to every address it has, and a single blocked address refused the whole name. LAN DNS servers commonly publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on such a name failed every login with "Requests to link-local / cloud-metadata addresses are not allowed". Immich, Synology and the other strict-guard integrations on the same kind of name failed the same way. Both guards now leave a blocked address out of the list the socket is pinned to and connect over the rest, and a name that resolves to nothing else is still refused. A private address among the remaining ones still needs ALLOW_INTERNAL_NETWORK under the strict guard, and ALLOW_LINK_LOCAL_IPS works as before. Leaving addresses out is only safe if the blocked set is complete, so the strict guard now blocks everything the admin lane does. The AWS IMDSv6 (fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened to any URL a user can type. Both guards also read an IPv4-mapped or IPv4-compatible address as the IPv4 it carries: Node prints an AAAA record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and ::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a link-local or metadata address now, and the strict guard never connects to loopback either.
229 lines
8.5 KiB
TypeScript
229 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import type { AddressInfo } from 'node:net';
|
|
|
|
// 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(),
|
|
}));
|
|
|
|
import dns from 'dns/promises';
|
|
import { safeFetchAdminConfigured, SsrfBlockedError } from '../../../src/utils/ssrfGuard';
|
|
|
|
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]);
|
|
});
|
|
});
|