mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -11,10 +12,19 @@ vi.mock('dns/promises', () => ({
|
||||
}));
|
||||
|
||||
import dns from 'dns/promises';
|
||||
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
|
||||
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';
|
||||
@@ -32,6 +42,7 @@ const listen = (server: http.Server, host: string) =>
|
||||
});
|
||||
|
||||
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'));
|
||||
@@ -44,12 +55,14 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -97,3 +110,119 @@ describe('a dual-stack name where one family does not answer', () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,14 @@ function mockIp(ip: string) {
|
||||
mockLookup.mockResolvedValue({ address: ip, family: ip.includes(':') ? 6 : 4 });
|
||||
}
|
||||
|
||||
/** What the last pinned dispatcher hands a socket that asks for every address. */
|
||||
function pinnedList(): unknown {
|
||||
const lookup = agentCapture.options.connect.lookup as (h: string, o: object, cb: (...a: unknown[]) => void) => void;
|
||||
const seen: unknown[] = [];
|
||||
lookup('any.example', { all: true }, (...args: unknown[]) => seen.push(...args));
|
||||
return seen[1];
|
||||
}
|
||||
|
||||
describe('checkSsrf', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -227,6 +235,31 @@ describe('checkSsrf', () => {
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.isPrivate).toBe(false);
|
||||
});
|
||||
|
||||
// An IPv4-compatible address (::a.b.c.d) is the IPv4 it carries as well. Node
|
||||
// prints an AAAA record of ::a9fe:a9fe as ::169.254.169.254, so that is the
|
||||
// spelling that reaches the guard, and it used to pass as a public address.
|
||||
it.each([
|
||||
['compatible metadata, the spelling Node prints', '::169.254.169.254'],
|
||||
['compatible metadata, hex', '::a9fe:a9fe'],
|
||||
['compatible Alibaba metadata', '::100.100.100.200'],
|
||||
['compatible loopback', '::127.0.0.1'],
|
||||
['compatible loopback, hex', '::7f00:1'],
|
||||
['loopback written out', '0:0:0:0:0:0:0:1'],
|
||||
])('SEC-013: always blocks %s (%s)', async (_label, ip) => {
|
||||
mockIp(ip);
|
||||
const result = await checkSsrf('http://attacker.example');
|
||||
expect(result).toMatchObject({ allowed: false, error: 'Requests to loopback and link-local addresses are not allowed' });
|
||||
});
|
||||
|
||||
it('SEC-013: treats a compatible RFC-1918 address as private and a compatible public one as public', async () => {
|
||||
mockIp('::10.0.0.1');
|
||||
expect(await checkSsrf('http://attacker.example')).toMatchObject({
|
||||
allowed: false, isPrivate: true, error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'),
|
||||
});
|
||||
mockIp('::8.8.8.8');
|
||||
expect(await checkSsrf('http://cdn.example')).toMatchObject({ allowed: true, isPrivate: false });
|
||||
});
|
||||
});
|
||||
|
||||
// SEC-014 — fe80::/10 is ten bits, not the four characters 'fe80'
|
||||
@@ -596,6 +629,11 @@ describe('safeFetchLlm', () => {
|
||||
await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError);
|
||||
mockIp('::a9fe:a9fe');
|
||||
await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError);
|
||||
// What Node actually hands back for an AAAA record of ::a9fe:a9fe.
|
||||
mockIp('::169.254.169.254');
|
||||
await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError);
|
||||
mockIp('::100.100.100.200');
|
||||
await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it('blocks the AWS IMDSv6 ULA endpoint but allows other ULA (a LAN model server)', async () => {
|
||||
@@ -690,6 +728,22 @@ describe('safeFetchLlm', () => {
|
||||
await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it('blocks the Alibaba metadata IPs in their IPv4-mapped spellings, and only those', async () => {
|
||||
const okFetch = vi.fn().mockResolvedValue({ ok: true } as Response);
|
||||
vi.stubGlobal('fetch', okFetch);
|
||||
for (const ip of ['::ffff:100.100.100.200', '::ffff:6464:64c8', '::ffff:6464:6464']) {
|
||||
mockIp(ip);
|
||||
await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError);
|
||||
}
|
||||
expect(okFetch).not.toHaveBeenCalled();
|
||||
// A mapped LAN or CGNAT model server is still a model server.
|
||||
for (const ip of ['::ffff:192.168.1.50', '::ffff:100.100.100.201']) {
|
||||
mockIp(ip);
|
||||
await safeFetchLlm('http://model.example/chat');
|
||||
}
|
||||
expect(okFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NAT64 → metadata', '64:ff9b::a9fe:a9fe'],
|
||||
['6to4 → metadata', '2002:a9fe:a9fe::'],
|
||||
@@ -752,6 +806,14 @@ describe('ALLOW_LINK_LOCAL_IPS (#2400)', () => {
|
||||
await guard.safeFetchAdminConfigured('https://keycloak.example.com/realms/trek/.well-known/openid-configuration');
|
||||
|
||||
expect(okFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The IPv4-mapped spellings of the listed address are the same address, as
|
||||
// they already were under the strict guard.
|
||||
for (const ip of ['::ffff:169.254.1.2', '::ffff:a9fe:102']) {
|
||||
resolve(ip);
|
||||
await guard.safeFetchAdminConfigured('https://keycloak.example.com/');
|
||||
}
|
||||
expect(okFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('refuses the gateway as before while nothing is listed, whatever ALLOW_INTERNAL_NETWORK says', async () => {
|
||||
@@ -805,6 +867,7 @@ describe('dual-stack names: every address is checked and every address is pinned
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
mockLookup.mockReset();
|
||||
agentCapture.options = null;
|
||||
});
|
||||
|
||||
const resolveAll = (entries: { address: string; family: number }[]) =>
|
||||
@@ -831,13 +894,15 @@ describe('dual-stack names: every address is checked and every address is pinned
|
||||
expect(result.resolvedIp).toBe('10.0.0.5');
|
||||
});
|
||||
|
||||
it('SEC-DUAL-003: a loopback address hidden behind a public one is still loopback', async () => {
|
||||
it('SEC-DUAL-003: a loopback address behind a public one is never handed to the socket', async () => {
|
||||
resolveAll([{ address: '203.0.113.10', family: 4 }, { address: '::1', family: 6 }]);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 200, ok: true, headers: { get: () => null } }));
|
||||
|
||||
const result = await checkSsrf('https://rebind.example');
|
||||
await safeFetchFollow('https://rebind.example/');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.error).toContain('loopback');
|
||||
expect(result).toMatchObject({ allowed: true, isPrivate: false, resolvedIp: '203.0.113.10', resolvedIps: ['203.0.113.10'] });
|
||||
expect(pinnedList()).toEqual([{ address: '203.0.113.10', family: 4 }]);
|
||||
});
|
||||
|
||||
it('SEC-DUAL-004: the pinned dispatcher hands the socket the whole checked list, IPv4 first', async () => {
|
||||
@@ -860,13 +925,15 @@ describe('dual-stack names: every address is checked and every address is pinned
|
||||
expect(single).toEqual([null, '203.0.113.10', 4]);
|
||||
});
|
||||
|
||||
it('SEC-DUAL-005: the admin lane blocks a name with a metadata address anywhere in its answer', async () => {
|
||||
resolveAll([{ address: '203.0.113.10', family: 4 }, { address: '169.254.169.254', family: 4 }]);
|
||||
const fetchSpy = vi.fn();
|
||||
it('SEC-DUAL-005: the admin lane never hands a metadata address in the answer to the socket', async () => {
|
||||
resolveAll([{ address: '169.254.169.254', family: 4 }, { address: '203.0.113.10', family: 4 }]);
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: { get: () => null } });
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await expect(safeFetchLlm('https://models.example/v1')).rejects.toThrow(SsrfBlockedError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
await safeFetchLlm('https://models.example/v1');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pinnedList()).toEqual([{ address: '203.0.113.10', family: 4 }]);
|
||||
});
|
||||
|
||||
it('SEC-DUAL-006: a redirect hop resolves and pins its own full list again', async () => {
|
||||
@@ -908,3 +975,274 @@ describe('dual-stack names: every address is checked and every address is pinned
|
||||
expect(result.error).toBe('Could not resolve hostname (ENOTFOUND)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a name with a link-local record next to a usable one (#2506)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
agentCapture.options = null;
|
||||
});
|
||||
|
||||
// The flags are read when the module loads, so each case loads a fresh copy of
|
||||
// the guard under its own environment and resolver answer.
|
||||
async function guardWith(env: Record<string, string>, answer: { address: string; family: number }[]) {
|
||||
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
|
||||
vi.resetModules();
|
||||
const guard = await import('../../../src/utils/ssrfGuard');
|
||||
const lookup = vi.mocked((await import('dns/promises')).default.lookup);
|
||||
lookup.mockResolvedValue(answer as never);
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: new Headers() });
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
return { guard, lookup, fetchSpy };
|
||||
}
|
||||
|
||||
const v4 = (address: string) => ({ address, family: 4 });
|
||||
const v6 = (address: string) => ({ address, family: 6 });
|
||||
const redirectTo = (location: string) => ({
|
||||
status: 302,
|
||||
ok: false,
|
||||
headers: new Headers({ location }),
|
||||
body: { cancel: () => Promise.resolve() },
|
||||
});
|
||||
|
||||
// The second reporter's DNS: the IdP's LAN address with an fe80:: AAAA beside it.
|
||||
const REPORTED = [v4('10.0.40.239'), v6('fe80::1')];
|
||||
// How Windows answers for its own name: the link-local addresses come first.
|
||||
const LINK_LOCAL_FIRST = [v6('fe80::b67e:9b1c:8625:40a7'), v6('fe80::f9f1:353:73:14a0'), v4('192.168.178.36')];
|
||||
// Every spelling of link-local and cloud metadata both lanes refuse. The
|
||||
// IPv4-compatible ones come as ::169.254.169.254 from a real resolver, which
|
||||
// prints ::/96 in dotted form; the hex spelling is what an IP literal keeps.
|
||||
const METADATA_AND_LINK_LOCAL = [
|
||||
v4('169.254.169.254'), v4('169.254.0.1'), v4('100.100.100.200'), v4('100.100.100.100'),
|
||||
v6('fe80::1'), v6('fe80::1%eth0'), v6('febf::1'), v6('fd00:ec2::254'), v6('fd00:0ec2::254'),
|
||||
v6('::ffff:169.254.169.254'), v6('::ffff:a9fe:a9fe'), v6('::a9fe:a9fe'), v6('::169.254.169.254'),
|
||||
v6('::ffff:100.100.100.200'), v6('::ffff:6464:6464'), v6('::100.100.100.200'),
|
||||
v6('64:ff9b::a9fe:a9fe'), v6('2002:a9fe:a9fe::'), v6('2001::5601:5601'), v6('64:ff9b::6464:64c8'),
|
||||
];
|
||||
|
||||
describe('the admin lane (OIDC, plugin OAuth, model endpoints, own routing engine)', () => {
|
||||
it('SEC-2506-001: OIDC discovery reaches the IdP over its LAN address and never the fe80:: one', async () => {
|
||||
const { guard, fetchSpy } = await guardWith({}, REPORTED);
|
||||
|
||||
await guard.safeFetchAdminConfigured('https://auth.home.example/.well-known/openid-configuration');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pinnedList()).toEqual([v4('10.0.40.239')]);
|
||||
// A socket that asks for one address gets the same vetted one.
|
||||
const single: unknown[] = [];
|
||||
agentCapture.options.connect.lookup('auth.home.example', {}, (...args: unknown[]) => single.push(...args));
|
||||
expect(single).toEqual([null, '10.0.40.239', 4]);
|
||||
});
|
||||
|
||||
it('SEC-2506-002: link-local records ahead of the IPv4 change nothing', async () => {
|
||||
const { guard, fetchSpy } = await guardWith({}, LINK_LOCAL_FIRST);
|
||||
|
||||
await guard.safeFetchAdminConfigured('http://maurice-pc:3191/.well-known/openid-configuration');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pinnedList()).toEqual([v4('192.168.178.36')]);
|
||||
});
|
||||
|
||||
it('SEC-2506-003: no link-local or metadata spelling reaches the socket, whatever ALLOW_INTERNAL_NETWORK says', async () => {
|
||||
for (const flag of ['true', 'false']) {
|
||||
const { guard } = await guardWith(
|
||||
{ ALLOW_INTERNAL_NETWORK: flag },
|
||||
[...METADATA_AND_LINK_LOCAL, v4('10.0.40.239'), v6('fd12:3456::1')],
|
||||
);
|
||||
|
||||
await guard.safeFetchAdminConfigured('https://auth.home.example/');
|
||||
|
||||
expect(pinnedList()).toEqual([v4('10.0.40.239'), v6('fd12:3456::1')]);
|
||||
}
|
||||
});
|
||||
|
||||
it('SEC-2506-004: a name with nothing but link-local and metadata records is still refused', async () => {
|
||||
const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, METADATA_AND_LINK_LOCAL);
|
||||
|
||||
await expect(guard.safeFetchAdminConfigured('https://auth.home.example/')).rejects.toThrow(
|
||||
'Requests to link-local / cloud-metadata addresses are not allowed',
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SEC-2506-005: an address listed in ALLOW_LINK_LOCAL_IPS stays usable, the rest of 169.254 does not', async () => {
|
||||
const { guard } = await guardWith(
|
||||
{ ALLOW_LINK_LOCAL_IPS: '169.254.1.2' },
|
||||
[v4('169.254.169.254'), v4('169.254.1.2'), v4('169.254.1.3'), v6('fe80::1')],
|
||||
);
|
||||
|
||||
await guard.safeFetchAdminConfigured('https://keycloak.example.com/');
|
||||
|
||||
expect(pinnedList()).toEqual([v4('169.254.1.2')]);
|
||||
});
|
||||
|
||||
it('SEC-2506-006: every redirect hop leaves out its own link-local records and pins what is left', async () => {
|
||||
const { guard, lookup, fetchSpy } = await guardWith({}, REPORTED);
|
||||
lookup.mockResolvedValueOnce(REPORTED as never).mockResolvedValueOnce(LINK_LOCAL_FIRST as never);
|
||||
const pins: unknown[] = [];
|
||||
fetchSpy.mockImplementation(async () => {
|
||||
pins.push(pinnedList());
|
||||
return pins.length === 1
|
||||
? redirectTo('https://second.home.example/')
|
||||
: { status: 200, ok: true, headers: new Headers() };
|
||||
});
|
||||
|
||||
const res = await guard.safeFetchAdminConfigured('https://auth.home.example/');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pins).toEqual([[v4('10.0.40.239')], [v4('192.168.178.36')]]);
|
||||
});
|
||||
|
||||
it('SEC-2506-007: a redirect to a name with only link-local records is refused before its fetch', async () => {
|
||||
const { guard, lookup, fetchSpy } = await guardWith({}, REPORTED);
|
||||
lookup.mockResolvedValueOnce(REPORTED as never).mockResolvedValue([v4('169.254.169.254'), v6('fe80::1')] as never);
|
||||
fetchSpy.mockResolvedValueOnce(redirectTo('http://metadata.example/latest/meta-data/'));
|
||||
|
||||
await expect(guard.safeFetchAdminConfigured('https://auth.home.example/')).rejects.toThrow(guard.SsrfBlockedError);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the strict guard (Immich, Synology, AirTrail, Dawarich, document stores, webhooks)', () => {
|
||||
it('SEC-2506-010: a LAN name with an fe80:: record works once ALLOW_INTERNAL_NETWORK is on', async () => {
|
||||
const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, REPORTED);
|
||||
|
||||
expect(await guard.checkSsrf('https://immich.home.example')).toMatchObject({
|
||||
allowed: true, isPrivate: true, resolvedIp: '10.0.40.239', resolvedIps: ['10.0.40.239'],
|
||||
});
|
||||
await guard.safeFetch('https://immich.home.example/api/users/me');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pinnedList()).toEqual([v4('10.0.40.239')]);
|
||||
});
|
||||
|
||||
it('SEC-2506-011: with the flag off the refusal names ALLOW_INTERNAL_NETWORK, not link-local', async () => {
|
||||
const { guard } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'false' }, REPORTED);
|
||||
|
||||
expect(await guard.checkSsrf('https://immich.home.example')).toMatchObject({
|
||||
allowed: false, isPrivate: true, resolvedIp: '10.0.40.239', error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'),
|
||||
});
|
||||
});
|
||||
|
||||
it('SEC-2506-012: loopback, link-local, metadata and the unspecified address never reach the socket', async () => {
|
||||
const { guard } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'false' }, [
|
||||
v4('127.0.0.1'), v6('::1'), v6('::'), v4('0.0.0.0'), v4('169.254.169.254'), v6('fe80::1'), v6('febf::1'),
|
||||
v6('::ffff:127.0.0.1'), v6('::ffff:7f00:1'), v6('::ffff:169.254.169.254'), v6('::ffff:a9fe:a9fe'),
|
||||
v6('64:ff9b::a9fe:a9fe'), v4('203.0.113.10'), v6('2001:db8::10'),
|
||||
]);
|
||||
|
||||
expect(await guard.checkSsrf('https://photos.example')).toMatchObject({
|
||||
allowed: true, isPrivate: false, resolvedIp: '203.0.113.10', resolvedIps: ['203.0.113.10', '2001:db8::10'],
|
||||
});
|
||||
await guard.safeFetchFollow('https://photos.example/');
|
||||
|
||||
expect(pinnedList()).toEqual([v4('203.0.113.10'), v6('2001:db8::10')]);
|
||||
});
|
||||
|
||||
it('SEC-2506-013: a name whose every record is always blocked is refused as before', async () => {
|
||||
const { guard, fetchSpy } = await guardWith(
|
||||
{ ALLOW_INTERNAL_NETWORK: 'true' },
|
||||
[v6('fe80::1'), v4('169.254.169.254'), v4('127.0.0.1'), v6('::1')],
|
||||
);
|
||||
|
||||
expect(await guard.checkSsrf('https://photos.example')).toMatchObject({
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
resolvedIp: '169.254.169.254',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
});
|
||||
await expect(guard.safeFetchFollow('https://photos.example/')).rejects.toThrow(guard.SsrfBlockedError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SEC-2506-014: a caller that refuses the internal network still refuses the LAN address behind it', async () => {
|
||||
const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, REPORTED);
|
||||
|
||||
expect(await guard.checkSsrf('https://x.home.example', true)).toMatchObject({
|
||||
allowed: false, isPrivate: true, error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'),
|
||||
});
|
||||
await expect(guard.safeFetchFollow('https://x.home.example/', undefined, { bypassInternalIpAllowed: true }))
|
||||
.rejects.toThrow(guard.SsrfBlockedError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SEC-2506-015: a private address beside a public one still decides for the whole name', async () => {
|
||||
const { guard } = await guardWith(
|
||||
{ ALLOW_INTERNAL_NETWORK: 'false' },
|
||||
[v6('fe80::1'), v4('203.0.113.10'), v4('10.0.0.5')],
|
||||
);
|
||||
|
||||
expect(await guard.checkSsrf('https://split.example')).toMatchObject({
|
||||
allowed: false, isPrivate: true, resolvedIp: '10.0.0.5', error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'),
|
||||
});
|
||||
});
|
||||
|
||||
it('SEC-2506-016: an address listed in ALLOW_LINK_LOCAL_IPS is internal next to an fe80:: record too', async () => {
|
||||
const { guard } = await guardWith(
|
||||
{ ALLOW_LINK_LOCAL_IPS: '169.254.1.2', ALLOW_INTERNAL_NETWORK: 'true' },
|
||||
[v6('fe80::1'), v4('169.254.169.254'), v4('169.254.1.2')],
|
||||
);
|
||||
|
||||
expect(await guard.checkSsrf('https://immich.example')).toMatchObject({
|
||||
allowed: true, isPrivate: true, resolvedIps: ['169.254.1.2'],
|
||||
});
|
||||
await guard.safeFetch('https://immich.example/');
|
||||
expect(pinnedList()).toEqual([v4('169.254.1.2')]);
|
||||
});
|
||||
|
||||
// ALLOW_INTERNAL_NETWORK opens ULA and CGNAT space, and the AWS IMDSv6 and
|
||||
// Alibaba metadata addresses sit in exactly those ranges. A URL a user typed
|
||||
// must not reach what an admin-configured endpoint cannot.
|
||||
it('SEC-2506-017: no link-local or metadata spelling reaches the socket, whatever ALLOW_INTERNAL_NETWORK says', async () => {
|
||||
const lan = await guardWith(
|
||||
{ ALLOW_INTERNAL_NETWORK: 'true' },
|
||||
[...METADATA_AND_LINK_LOCAL, v4('10.0.40.239'), v6('fd12:3456::1')],
|
||||
);
|
||||
expect(await lan.guard.checkSsrf('https://immich.home.example')).toMatchObject({
|
||||
allowed: true, isPrivate: true, resolvedIp: '10.0.40.239', resolvedIps: ['10.0.40.239', 'fd12:3456::1'],
|
||||
});
|
||||
await lan.guard.safeFetchFollow('https://immich.home.example/');
|
||||
expect(pinnedList()).toEqual([v4('10.0.40.239'), v6('fd12:3456::1')]);
|
||||
|
||||
const pub = await guardWith(
|
||||
{ ALLOW_INTERNAL_NETWORK: 'false' },
|
||||
[...METADATA_AND_LINK_LOCAL, v4('203.0.113.10'), v6('2001:db8::10')],
|
||||
);
|
||||
expect(await pub.guard.checkSsrf('https://photos.example')).toMatchObject({
|
||||
allowed: true, isPrivate: false, resolvedIps: ['203.0.113.10', '2001:db8::10'],
|
||||
});
|
||||
await pub.guard.safeFetchFollow('https://photos.example/');
|
||||
expect(pinnedList()).toEqual([v4('203.0.113.10'), v6('2001:db8::10')]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['AWS IMDSv6', 'fd00:ec2::254'],
|
||||
['Alibaba metadata', '100.100.100.200'],
|
||||
['Alibaba DNS', '100.100.100.100'],
|
||||
['Alibaba metadata, IPv4-mapped', '::ffff:100.100.100.200'],
|
||||
['metadata, IPv4-compatible', '::169.254.169.254'],
|
||||
])('SEC-2506-018: %s (%s) is refused beside an fe80:: record even with ALLOW_INTERNAL_NETWORK on', async (_label, ip) => {
|
||||
const record = ip.includes(':') ? v6(ip) : v4(ip);
|
||||
for (const answer of [[v6('fe80::1'), record], [record]]) {
|
||||
const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, answer);
|
||||
|
||||
expect(await guard.checkSsrf('https://photos.example')).toMatchObject({
|
||||
allowed: false, error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
});
|
||||
await expect(guard.safeFetchFollow('https://photos.example/')).rejects.toThrow(guard.SsrfBlockedError);
|
||||
await expect(guard.safeFetchAdminConfigured('https://photos.example/')).rejects.toThrow(guard.SsrfBlockedError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('SEC-2506-019: a metadata record beside a LAN address is left out and the LAN address pinned', async () => {
|
||||
const { guard } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, [v6('fd00:ec2::254'), v4('10.0.0.5')]);
|
||||
|
||||
expect(await guard.checkSsrf('https://nas.home.example')).toMatchObject({
|
||||
allowed: true, isPrivate: true, resolvedIp: '10.0.0.5', resolvedIps: ['10.0.0.5'],
|
||||
});
|
||||
await guard.safeFetch('https://nas.home.example/');
|
||||
expect(pinnedList()).toEqual([v4('10.0.0.5')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user