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