From 0887f251cece4b29a4be60fcdc8b68e1a6bb1393 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 19:44:40 +0200 Subject: [PATCH] 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. --- server/src/utils/ssrfGuard.ts | 119 +++--- .../integration/oidc-local-provider.test.ts | 269 +++++++++++++ .../tests/unit/utils/ssrf-dual-stack.test.ts | 131 ++++++- server/tests/unit/utils/ssrfGuard.test.ts | 354 +++++++++++++++++- wiki/Internal-Network-Access.md | 13 +- wiki/Troubleshooting.md | 2 + 6 files changed, 830 insertions(+), 58 deletions(-) create mode 100644 server/tests/integration/oidc-local-provider.test.ts diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 2e11bd06c..7e257fceb 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -14,7 +14,11 @@ export interface SsrfResult { allowed: boolean; /** The first of `resolvedIps`, kept for the callers that name one address. */ resolvedIp?: string; - /** Every address the name resolves to, each one checked, IPv4 first. */ + /** + * The addresses a connection to the name may use, IPv4 first: every address + * it resolves to, less the ones the guard never connects to. On a refusal, + * every address it resolved to. + */ resolvedIps?: string[]; isPrivate: boolean; error?: string; @@ -27,11 +31,11 @@ export interface SsrfResult { * the resolver lists first, on Node 22 in the resolver's own order, and a * dual-stack name whose AAAA comes first then pinned the connection to an IPv6 * that many hosts cannot reach on port 443 while the A record would have - * answered at once. The whole list goes to the socket, where Node tries the - * addresses in turn (autoSelectFamily, the default since Node 20), so an - * unreachable family costs a quarter of a second instead of the caller's whole - * timeout. IPv4 leads because that is the family that is reachable from the - * containers and LXCs TREK usually runs in. + * answered at once. Every address the guard allows goes to the socket, where + * Node tries the addresses in turn (autoSelectFamily, the default since Node + * 20), so an unreachable family costs a quarter of a second instead of the + * caller's whole timeout. IPv4 leads because that is the family that is + * reachable from the containers and LXCs TREK usually runs in. */ async function resolveAll(hostname: string): Promise<{ address: string; family: number }[]> { const result = await dns.lookup(hostname, { all: true }); @@ -49,16 +53,23 @@ async function resolveAll(hostname: string): Promise<{ address: string; family: } /** - * The IPv4 an IPv4-mapped address (`::ffff:a.b.c.d`) stands for, or null. + * The IPv4 an IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible (`::a.b.c.d`) + * address stands for, or null. * * Taken from the hextets rather than from the text, because `::ffff:127.0.0.1` * and `::ffff:7f00:1` are one address in two spellings and `dns.lookup` picks * which one comes back. The prefix regexes this replaces covered the dotted - * spelling of two ranges; every other mapped address walked past them. + * spelling of two ranges; every other mapped address walked past them. The + * compatible form is deprecated but still parsed, and Node prints an AAAA + * record of `::a9fe:a9fe` as `::169.254.169.254`. `::` and `::1` are left to + * the IPv6 checks, being the unspecified and loopback addresses themselves. */ -function mappedIpv4(hextets: number[]): string | null { +function carriedIpv4(hextets: number[]): string | null { const g = hextets; - if (g[0] || g[1] || g[2] || g[3] || g[4] || g[5] !== 0xffff) return null; + if (g[0] || g[1] || g[2] || g[3] || g[4]) return null; + const mapped = g[5] === 0xffff; + const compatible = g[5] === 0 && (g[6] !== 0 || g[7] > 1); + if (!mapped && !compatible) return null; return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`; } @@ -72,9 +83,12 @@ function isAlwaysBlocked(ip: string): boolean { if (addr.startsWith('127.') || addr === '::1') return true; // Unspecified if (addr.startsWith('0.')) return true; - // Link-local / cloud metadata, apart from an address listed in ALLOW_LINK_LOCAL_IPS, - // which isPrivateNetwork then treats as the internal network it is. - if (addr.startsWith('169.254.') && !ALLOWED_LINK_LOCAL.has(addr)) return true; + // Link-local and cloud metadata: everything the admin lane refuses, apart from + // an address listed in ALLOW_LINK_LOCAL_IPS, which isPrivateNetwork then treats + // as the internal network it is. The AWS IMDSv6 and Alibaba addresses sit in + // ULA and CGNAT space, so without this ALLOW_INTERNAL_NETWORK would open them + // to a URL any user can type. + if (isLinkLocal(addr)) return true; const hextets = expandIpv6(addr); if (hextets) { @@ -83,11 +97,13 @@ function isAlwaysBlocked(ip: string): boolean { // (`::`, `::0`, `0:0:0:0:0:0:0:0`) that only the expanded hextets settle it. // The `0.` check above never saw any of them: they begin with a colon. if (hextets.every(h => h === 0)) return true; + // Loopback in any spelling, not only the `::1` a resolver prints. + if (hextets.slice(0, 7).every(h => h === 0) && hextets[7] === 1) return true; // fe80::/10 spans fe80: through febf:, not just the four characters 'fe80'. if ((hextets[0] & 0xffc0) === 0xfe80) return true; - // A mapped address inherits the verdict of the IPv4 it carries. - const mapped = mappedIpv4(hextets); - if (mapped) return isAlwaysBlocked(mapped); + // A mapped or compatible address inherits the verdict of the IPv4 it carries. + const carried = carriedIpv4(hextets); + if (carried) return isAlwaysBlocked(carried); } // IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4. const embedded = embeddedTransitionIpv4(addr); @@ -111,12 +127,12 @@ function isPrivateNetwork(ip: string): boolean { if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true; // IPv6 ULA (fc00::/7) if (/^f[cd]/i.test(addr)) return true; - // A mapped address inherits the verdict of the IPv4 it carries. Deciding on the - // hextets rather than on three `::ffff:`-prefix regexes also covers the hex - // spelling and the CGNAT range those regexes never listed. + // A mapped or compatible address inherits the verdict of the IPv4 it carries. + // Deciding on the hextets rather than on three `::ffff:`-prefix regexes also + // covers the hex spelling and the CGNAT range those regexes never listed. const hextets = expandIpv6(addr); - const mapped = hextets && mappedIpv4(hextets); - if (mapped) return isPrivateNetwork(mapped); + const carried = hextets && carriedIpv4(hextets); + if (carried) return isPrivateNetwork(carried); // IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4. const embedded = embeddedTransitionIpv4(addr); if (embedded) return isPrivateNetwork(embedded); @@ -143,10 +159,7 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean const hostname = url.hostname.toLowerCase(); - // Resolve the name to every address it has. Each one is judged on its own, - // and a single blocked address blocks the name: the socket may end up on any - // of them, so a list that mixes a public and a private address is a private - // target with a public alibi. + // Resolve the name to every address it has and judge each one on its own. let resolvedIps: string[]; try { resolvedIps = (await resolveAll(hostname)).map((entry) => entry.address); @@ -154,20 +167,27 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown'; return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` }; } - const resolvedIp = resolvedIps[0]; - const blocked = resolvedIps.find((ip) => isAlwaysBlocked(ip)); - if (blocked) { + // An address the guard never connects to is left out of the list the socket + // gets, rather than refusing the name. A LAN DNS server commonly hands out a + // host's fe80:: address next to its IPv4, and refusing the name for it locked + // out every such host (#2506). Only a name with nothing else left is refused. + const usableIps = resolvedIps.filter((ip) => !isAlwaysBlocked(ip)); + if (usableIps.length === 0) { return { allowed: false, isPrivate: true, - resolvedIp: blocked, + resolvedIp: resolvedIps[0], resolvedIps, error: 'Requests to loopback and link-local addresses are not allowed', }; } + const resolvedIp = usableIps[0]; - const privateIp = resolvedIps.find((ip) => isPrivateNetwork(ip)); + // A private address among the rest decides for the whole name: the socket + // may end up on any address it is handed, so a list that mixes a public and a + // private address is a private target with a public alibi. + const privateIp = usableIps.find((ip) => isPrivateNetwork(ip)); if (privateIp || isInternalHostname(hostname)) { if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) { return { @@ -179,10 +199,10 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.', }; } - return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps }; + return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps: usableIps }; } - return { allowed: true, isPrivate: false, resolvedIp, resolvedIps }; + return { allowed: true, isPrivate: false, resolvedIp, resolvedIps: usableIps }; } /** Link-local / cloud-metadata addresses — never a legitimate model host. */ @@ -191,17 +211,23 @@ function isLinkLocal(ip: string): boolean { // IPv4 link-local — AWS, GCP, Azure and OpenStack all serve credentials from 169.254.169.254. // An address listed in ALLOW_LINK_LOCAL_IPS is the exception; those never include it. if (addr.startsWith('169.254.')) return !ALLOWED_LINK_LOCAL.has(addr); - // IPv4-mapped (::ffff:169.254.x) and IPv4-compatible (::a9fe:xxxx = ::169.254.x) spellings. - if (/^::ffff:169\.254\./.test(addr) || /^::(ffff:)?a9fe:/.test(addr)) return true; // IPv6 link-local fe80::/10 — the whole range (fe80: … febf:), not just the fe80: prefix. if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; - // AWS IMDSv6 sits in a fixed ULA slot; block it specifically while leaving the rest of - // fc00::/7 reachable (a self-hosted model server may legitimately sit on a ULA address). - if (addr === 'fd00:ec2::254' || addr.startsWith('fd00:ec2:')) return true; + // AWS IMDSv6 sits in a fixed ULA slot (fd00:ec2::/32); block it specifically while + // leaving the rest of fc00::/7 reachable (a self-hosted model server may + // legitimately sit on a ULA address). Read off the hextets, so every spelling counts. + const hextets = expandIpv6(addr); + if (hextets && hextets[0] === 0xfd00 && hextets[1] === 0x0ec2) return true; // Alibaba Cloud ECS metadata (100.100.100.200, and .100) lives in CGNAT space // (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server — // so block the specific metadata IPs directly rather than the whole range. if (addr === '100.100.100.200' || addr === '100.100.100.100') return true; + // An IPv4-mapped or IPv4-compatible address, dotted or hex, is the IPv4 it + // carries. So 169.254.169.254 cannot come back as ::ffff:a9fe:a9fe, or as the + // ::169.254.169.254 Node prints for a compatible AAAA record, and an address + // listed in ALLOW_LINK_LOCAL_IPS keeps its exception in every spelling. + const carried = hextets && carriedIpv4(hextets); + if (carried) return isLinkLocal(carried); // NAT64/6to4/Teredo embedding one of the above link-local/metadata IPs — extract // the embedded IPv4 and re-check. A transition address to a public or LAN IPv4 // stays allowed (safeFetchLlm deliberately permits private/loopback targets). @@ -257,11 +283,15 @@ export async function safeFetchAdminConfigured( const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown'; throw new SsrfBlockedError(`Could not resolve hostname (${code})`); } - if (resolvedIps.some((ip) => isLinkLocal(ip))) { + // As in checkSsrf: a link-local or metadata address is left out of the list + // the socket gets, and only a name with nothing else left is refused. An + // fe80:: record beside an IdP's LAN address refused every OIDC login (#2506). + const usableIps = resolvedIps.filter((ip) => !isLinkLocal(ip)); + if (usableIps.length === 0) { throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed'); } - const dispatcher = createPinnedDispatcher(resolvedIps, true, responseTimeoutMs); + const dispatcher = createPinnedDispatcher(usableIps, true, responseTimeoutMs); const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any); // Only a 3xx WITH a Location header is a redirect we follow; anything else @@ -414,8 +444,8 @@ export interface SafeFetchFollowOptions extends SafeFetchOptions { keepCredentialsOnRedirect?: boolean; /** * When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally - * permit are still blocked (matches `checkSsrf(url, true)`). Loopback and - * link-local are always blocked regardless. Defaults to false. + * permit are still blocked (matches `checkSsrf(url, true)`). Loopback, + * link-local and cloud metadata are always blocked regardless. Defaults to false. */ bypassInternalIpAllowed?: boolean; } @@ -495,9 +525,10 @@ export async function safeFetchFollow( * addresses. This prevents DNS rebinding (TOCTOU) by ensuring the outbound * connection goes to an address we checked, not a re-resolved one. * - * Given the whole checked list, in the order `resolveAll` put it, the socket - * gets every address at once: Node asks for `all: true` and walks the list - * itself, moving on after its attempt timeout when one family does not answer. + * Given the vetted list, in the order `resolveAll` put it, the socket gets + * every address on it at once and never one the guard left out: Node asks for + * `all: true` and walks the list itself, moving on after its attempt timeout + * when one family does not answer. * A caller that still names one address gets exactly the old behaviour. */ export function createPinnedDispatcher( diff --git a/server/tests/integration/oidc-local-provider.test.ts b/server/tests/integration/oidc-local-provider.test.ts new file mode 100644 index 000000000..82b7bb91e --- /dev/null +++ b/server/tests/integration/oidc-local-provider.test.ts @@ -0,0 +1,269 @@ +/** + * The whole OIDC login against a real provider on this machine (#2506). + * + * oidc.test.ts stubs the four provider calls to test the flow around them. This + * file stubs nothing but the resolver: the provider is a small HTTP server on + * loopback that answers discovery, authorize, token, JWKS (with a real RS256 + * signature) and userinfo, so every request the login makes goes through the + * SSRF guard to a socket. + * + * Its name resolves the way a LAN DNS server answers for a self-hosted IdP: an + * fe80:: record next to the reachable address. Since 4.3.0 that one record + * refused the name, and every login ended in `{"error":"OIDC login failed"}`. + * Loopback stands in for the LAN address, being the one address a test can + * reach; the admin lane OIDC rides on treats the two alike. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import request from 'supertest'; +import http from 'node:http'; +import crypto from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; + +const { testDb, dbMock, answers } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock, answers: new Map() }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + SESSION_DURATION_REMEMBER: '30d', + SESSION_DURATION_REMEMBER_MS: 2592000000, + SESSION_DURATION_REMEMBER_SECONDS: 2592000, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); +// The provider's names are answered here; every other name goes to the real resolver. +vi.mock('dns/promises', async (importOriginal) => { + const real = await importOriginal(); + const lookup = ((hostname: string, options?: { all?: boolean }) => { + const answer = answers.get(hostname); + if (!answer) return real.lookup(hostname, options as never); + return Promise.resolve(options?.all ? answer : answer[0]); + }) as typeof real.lookup; + return { ...real, default: { ...real, lookup }, lookup }; +}); + +import { buildApp } from '../../src/bootstrap'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import { createUser } from '../helpers/factories'; + +const CLIENT_ID = 'trek-local'; +const CLIENT_SECRET = 'local-secret'; +const EMAIL = 'lan-admin@home.test'; + +/** Read one cookie's value out of a response's Set-Cookie header, as a browser would. */ +function readCookie(res: request.Response, name: string): string | undefined { + const raw = res.headers['set-cookie']; + const all: string[] = Array.isArray(raw) ? raw : raw ? [raw as unknown as string] : []; + const value = all.filter((c) => c.startsWith(`${name}=`)).pop()?.split(';')[0].slice(name.length + 1); + return value ? decodeURIComponent(value) : undefined; +} + +// ── The provider ──────────────────────────────────────────────────────────── + +const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256', use: 'sig' }; +const codes = new Set(); +/** Every request the provider saw: method, path and the address the client dialled. */ +const seen: { method: string; path: string; local: string }[] = []; +let provider: http.Server; +let port = 0; + +const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); +function signIdToken(issuer: string): string { + const now = Math.floor(Date.now() / 1000); + const signingInput = `${b64({ alg: 'RS256', kid: 'k1', typ: 'JWT' })}.${b64({ + iss: issuer, sub: 'lan-sub-1', aud: CLIENT_ID, iat: now, exp: now + 300, email: EMAIL, email_verified: true, + })}`; + return `${signingInput}.${crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url')}`; +} + +function answerJson(res: http.ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function handle(req: http.IncomingMessage, res: http.ServerResponse) { + // The Host header carries the name TREK asked for, so one server plays every issuer. + const issuer = `http://${req.headers.host}`; + const url = new URL(req.url ?? '/', issuer); + seen.push({ method: req.method ?? '', path: url.pathname, local: req.socket.localAddress ?? '' }); + if (url.pathname === '/.well-known/openid-configuration') { + return answerJson(res, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks`, + }); + } + if (url.pathname === '/authorize') { + const code = crypto.randomBytes(12).toString('hex'); + codes.add(code); + const back = new URL(url.searchParams.get('redirect_uri') ?? ''); + back.searchParams.set('code', code); + back.searchParams.set('state', url.searchParams.get('state') ?? ''); + res.writeHead(302, { location: back.toString() }); + return res.end(); + } + if (url.pathname === '/token' && req.method === 'POST') { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + const form = new URLSearchParams(raw); + const code = form.get('code') ?? ''; + if (!codes.delete(code) || form.get('client_secret') !== CLIENT_SECRET || !form.get('code_verifier')) { + return answerJson(res, 400, { error: 'invalid_grant' }); + } + answerJson(res, 200, { access_token: 'lan-access', token_type: 'Bearer', id_token: signIdToken(issuer) }); + }); + return; + } + if (url.pathname === '/userinfo') { + if (req.headers.authorization !== 'Bearer lan-access') return answerJson(res, 401, { error: 'invalid_token' }); + return answerJson(res, 200, { sub: 'lan-sub-1', email: EMAIL, email_verified: true, name: 'LAN Admin' }); + } + if (url.pathname === '/jwks') return answerJson(res, 200, { keys: [jwk] }); + answerJson(res, 404, { error: 'not_found' }); +} + +// ── The app ───────────────────────────────────────────────────────────────── + +let nestApp: INestApplication; +let app: Application; + +beforeAll(async () => { + provider = http.createServer(handle); + await new Promise((resolve) => provider.listen(0, '127.0.0.1', resolve)); + port = (provider.address() as AddressInfo).port; + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); + +beforeEach(() => { + resetTestDb(testDb); + resetRateLimits(nestApp); + seen.length = 0; + answers.clear(); + process.env.OIDC_CLIENT_ID = CLIENT_ID; + process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET; + process.env.APP_URL = 'http://localhost:3001'; +}); + +afterEach(() => { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.APP_URL; + vi.restoreAllMocks(); +}); + +afterAll(async () => { + await nestApp.close(); + await new Promise((resolve) => provider.close(resolve)); + testDb.close(); +}); + +/** Point TREK at the provider under `host`, answering the name with `answer`. */ +function useIssuer(host: string, answer: { address: string; family: number }[]): string { + answers.set(host, answer); + const issuer = `http://${host}:${port}`; + process.env.OIDC_ISSUER = issuer; + return issuer; +} + +describe('OIDC login against a provider whose name has an fe80:: record (#2506)', () => { + it('OIDC-LAN-001: signs in end to end, and every provider request lands on the vetted address', async () => { + const { user } = createUser(testDb, { email: EMAIL }); + // Link-local first, the order Windows and some LAN resolvers hand it out in. + const issuer = useIssuer('idp.home.test', [ + { address: 'fe80::1', family: 6 }, + { address: 'fe80::f9f1:353:73:14a0', family: 6 }, + { address: '127.0.0.1', family: 4 }, + ]); + + const login = await request(app).get('/api/auth/oidc/login'); + expect(login.status).toBe(302); + const authorize = new URL(login.headers.location!); + expect(authorize.origin).toBe(issuer); + const state = readCookie(login, 'trek_oidc_state')!; + expect(authorize.searchParams.get('state')).toBe(state); + + // The browser's leg: the provider sends it back to /callback with a code. + const back = await fetch(`http://127.0.0.1:${port}${authorize.pathname}${authorize.search}`, { redirect: 'manual' }); + const callbackUrl = new URL(back.headers.get('location')!); + const cb = await request(app) + .get(`${callbackUrl.pathname}${callbackUrl.search}`) + .set('Cookie', `trek_oidc_state=${state}`); + expect(cb.status).toBe(302); + const landing = new URL(cb.headers.location!, 'http://localhost'); + expect(landing.searchParams.get('oidc_error')).toBeNull(); + const oidcCode = landing.searchParams.get('oidc_code')!; + expect(oidcCode).toBeTruthy(); + + const exchange = await request(app) + .get(`/api/auth/oidc/exchange?code=${oidcCode}`) + .set('Cookie', `trek_oidc_exchange=${readCookie(cb, 'trek_oidc_exchange')}`); + expect(exchange.status).toBe(200); + const session = readCookie(exchange, 'trek_session'); + expect(session).toBeTruthy(); + + const me = await request(app).get('/api/auth/me').set('Cookie', `trek_session=${session}`); + expect(me.status).toBe(200); + expect(me.body.user).toMatchObject({ id: user.id, email: EMAIL }); + + // Discovery, token, JWKS and userinfo all went out, each over loopback. + const server = seen.filter((r) => r.path !== '/authorize'); + expect(server.map((r) => `${r.method} ${r.path}`)).toEqual([ + 'GET /.well-known/openid-configuration', + 'POST /token', + 'GET /jwks', + 'GET /userinfo', + ]); + expect(new Set(server.map((r) => r.local))).toEqual(new Set(['127.0.0.1'])); + }); + + it('OIDC-LAN-002: a name with nothing but link-local and metadata records is still refused', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + useIssuer('metadata.home.test', [ + { address: 'fe80::1', family: 6 }, + { address: '169.254.169.254', family: 4 }, + { address: 'fd00:ec2::254', family: 6 }, + ]); + + const login = await request(app).get('/api/auth/oidc/login'); + + expect(login.status).toBe(500); + expect(login.body).toEqual({ error: 'OIDC login failed' }); + expect(errors).toHaveBeenCalledWith( + '[OIDC] Login error:', + 'Requests to link-local / cloud-metadata addresses are not allowed', + ); + expect(seen).toEqual([]); + }); +}); diff --git a/server/tests/unit/utils/ssrf-dual-stack.test.ts b/server/tests/unit/utils/ssrf-dual-stack.test.ts index 36122c1b2..3cfb0708f 100644 --- a/server/tests/unit/utils/ssrf-dual-stack.test.ts +++ b/server/tests/unit/utils/ssrf-dual-stack.test.ts @@ -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]); + }); +}); diff --git a/server/tests/unit/utils/ssrfGuard.test.ts b/server/tests/unit/utils/ssrfGuard.test.ts index 95a1ed15b..9c108a46b 100644 --- a/server/tests/unit/utils/ssrfGuard.test.ts +++ b/server/tests/unit/utils/ssrfGuard.test.ts @@ -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, 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')]); + }); + }); +}); diff --git a/wiki/Internal-Network-Access.md b/wiki/Internal-Network-Access.md index ebb6832ac..abf6a0d97 100644 --- a/wiki/Internal-Network-Access.md +++ b/wiki/Internal-Network-Access.md @@ -19,11 +19,12 @@ Under the strict guard, these ranges are blocked whatever `ALLOW_INTERNAL_NETWOR | Range | Description | |---|---| | `127.0.0.0/8`, `::1` | Loopback | -| `0.0.0.0/8` | Unspecified | +| `0.0.0.0/8`, `::` | Unspecified | | `169.254.0.0/16`, `fe80::/10` | Link-local / cloud metadata endpoints | -| `::ffff:127.x.x.x`, `::ffff:169.254.x.x` | IPv4-mapped loopback and link-local | +| `fd00:ec2::/32`, `100.100.100.200`, `100.100.100.100` | AWS (IMDSv6) and Alibaba Cloud metadata. They sit inside the ULA and CGNAT ranges below and stay blocked when those open up | +| IPv4-mapped, IPv4-compatible and NAT64/6to4/Teredo forms of the above | e.g. `::ffff:127.0.0.1`, `::169.254.169.254`, `64:ff9b::a9fe:a9fe` | -The IPv6 link-local rule covers the whole `fe80::/10` prefix (`fe80:` to `febf:`), under the relaxed guard as well. +The IPv6 link-local rule covers the whole `fe80::/10` prefix (`fe80:` to `febf:`). The link-local and metadata rows hold under the relaxed guard as well. The one way past this table is `ALLOW_LINK_LOCAL_IPS`, for a single IPv4 link-local address, see [below](#a-link-local-address-you-need). @@ -41,7 +42,7 @@ The one way past this table is `ALLOW_LINK_LOCAL_IPS`, for a single IPv4 link-lo The hostname `localhost` is matched at the hostname stage too, but it normally resolves to a loopback address (`127.0.0.1` or `::1`), which the always-blocked loopback rule catches first, so under the strict guard it is blocked no matter how `ALLOW_INTERNAL_NETWORK` is set. On a host that maps `localhost` somewhere else, the hostname rule still applies and it stays blocked unless `ALLOW_INTERNAL_NETWORK=true`. The relaxed guard allows `localhost` outright, which is what makes a local Ollama the supported default for AI Parsing. -`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true`: the guard still resolves them to an IP and enforces all IP-level rules, so any such hostname that resolves to a loopback or link-local address remains blocked regardless. +`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true`: the guard still resolves them to an IP and enforces all IP-level rules, so TREK never connects to a loopback or link-local address such a hostname resolves to, and a hostname with no other address remains blocked regardless. ## When to enable @@ -71,7 +72,9 @@ Several addresses are separated by commas. The list is read at startup, so resta ## DNS rebinding protection -Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the IP. The outbound connection is then made directly to that IP using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request. +Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the addresses it may use. The outbound connection is then made directly to those addresses using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request. + +A hostname can resolve to several addresses, and a LAN DNS server often hands out a host's IPv6 link-local address (`fe80::`) next to its IPv4 one. TREK never connects to an address the guard blocks: it leaves that address out and connects over the ones that remain. Only a hostname whose addresses are all blocked is refused. Under the strict guard, a private address among the rest still needs `ALLOW_INTERNAL_NETWORK=true`. ## Audit log diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index aeedbd7b5..d7458672e 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -269,6 +269,8 @@ Set `OIDC_ISSUER` to that exact string. **Cause:** Not the SSRF guard, despite what it looks like. All four OIDC calls (discovery, token, userinfo, JWKS) go through the admin-configured fetch path, which deliberately **allows** loopback and private/LAN targets: a Keycloak or Authentik on `192.168.x` or `10.x` is a supported setup and needs no extra variable. `ALLOW_INTERNAL_NETWORK` belongs to the guard on *user*-supplied URLs and changes nothing about OIDC. The only addresses that path refuses are link-local and cloud-metadata ones (`169.254.0.0/16`, `fe80::/10`), which fail with `Requests to link-local / cloud-metadata addresses are not allowed`. A provider behind the host gateway of a rootless Podman container resolves to `169.254.1.2` and fails exactly like that; list that address in `ALLOW_LINK_LOCAL_IPS`, see [Internal-Network-Access](Internal-Network-Access#a-link-local-address-you-need). +Versions 4.3.0 to 4.3.2 also failed like that when the provider's hostname had an IPv6 link-local (`fe80::`) record next to its LAN address, which many LAN DNS servers hand out. From 4.3.3 on, TREK leaves such an address out and connects over the LAN address, so only a hostname that resolves to nothing but link-local or metadata addresses still fails. + **Fix:** Look for the reasons an internal provider actually fails. A failed discovery fetch answers `500 { "error": "OIDC login failed" }` and logs the real message as `[OIDC] Login error: …`, so start there: ```bash