fix(ssrf): let a listed link-local address through, such as a rootless Podman host gateway

A rootless Podman container reaches its host through 169.254.1.2, and an
identity provider behind that address failed the OIDC login with "Requests
to link-local / cloud-metadata addresses are not allowed", with no setting
to change it.

ALLOW_LINK_LOCAL_IPS names single link-local IPv4 addresses that may be
reached after all. The admin-configured lane (OIDC, AI parsing, plugin
OAuth, routing engines) reaches a listed address directly; the guard on
user-supplied URLs treats it as internal, so it also needs
ALLOW_INTERNAL_NETWORK there. 169.254.169.0/24 and 169.254.170.0/24, where
clouds serve instance metadata and credentials, cannot be listed, and an
entry that is not one link-local IPv4 aborts startup. Unset, nothing
changes.

Closes #2400
This commit is contained in:
Maurice
2026-09-19 22:39:12 +02:00
parent 00de3c3a1d
commit 7cfec6452d
17 changed files with 192 additions and 11 deletions
+1
View File
@@ -26,6 +26,7 @@ FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-ins
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
# ALLOW_LINK_LOCAL_IPS=169.254.1.2 # Single link-local addresses TREK may reach after all, comma-separated, e.g. the host gateway of a rootless Podman container. OIDC reaches a listed address directly; other integrations also need ALLOW_INTERNAL_NETWORK=true. 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed.
APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP
+1 -1
View File
@@ -67,7 +67,7 @@ DI in Nest, module-top `readEnv()` consts elsewhere):
PORT, HOST, TRUST_PROXY, SESSION_DURATION(_REMEMBER), MCP_SESSION_TTL,
MCP_MAX_SESSION_PER_USER, MCP_SSE_KEEPALIVE, TREK_PLUGIN_RPC_*/LOG_*/MAX_RSS_MB,
TREK_PLUGIN_REGISTRY_URL, TREK_WIKI_DIR*, TREK_PLACE_PHOTO_DIR, BACKUP_*,
TRANSIT_API_URL, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, DEFAULT_LANGUAGE,
TRANSIT_API_URL, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, ALLOW_LINK_LOCAL_IPS*, DEFAULT_LANGUAGE,
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY**.
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
+2
View File
@@ -21,6 +21,7 @@ import {
numberOr,
parseBool,
parseDurationMs,
parseLinkLocalAllowList,
positiveIntOr,
positiveNumberOr,
resolveDurability,
@@ -323,6 +324,7 @@ export function derivePaths(raw: RawEnv) {
export function deriveNet(raw: RawEnv) {
return {
allowInternalNetwork: parseBool(raw.ALLOW_INTERNAL_NETWORK) === true,
allowLinkLocalIps: parseLinkLocalAllowList(raw.ALLOW_LINK_LOCAL_IPS).ips,
};
}
+6 -1
View File
@@ -11,7 +11,7 @@
*/
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import { parseDurationMs } from './parsers';
import { parseDurationMs, parseLinkLocalAllowList } from './parsers';
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
function optionalWith(test: (v: string) => boolean, message: string) {
@@ -74,6 +74,11 @@ export const envSchema = z.object({
HSTS_INCLUDE_SUBDOMAINS: boolStr,
TRUST_PROXY: integer(0, 2 ** 31, 'must be an integer (number of trusted proxy hops)'),
ALLOW_INTERNAL_NETWORK: boolStr,
ALLOW_LINK_LOCAL_IPS: optionalWith(
(v) => parseLinkLocalAllowList(v).invalid.length === 0,
'must be a comma-separated list of IPv4 addresses in 169.254.0.0/16, such as 169.254.1.2; ' +
'169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed',
),
IDEMPOTENCY_TTL_SECONDS: positiveNumber,
// OIDC
+27
View File
@@ -182,3 +182,30 @@ export function resolveDurability(rawJournalMode: string | undefined, rawSynchro
export function synchronousName(level: unknown): string {
return SYNCHRONOUS_LEVELS[Number(level)] ?? String(level);
}
/**
* ALLOW_LINK_LOCAL_IPS: single IPv4 addresses out of 169.254.0.0/16 that outbound
* requests may reach after all. The case it exists for is the host gateway of a
* rootless Podman container, 169.254.1.2, with an identity provider or another
* service behind it (#2400).
*
* 169.254.169.0/24 and 169.254.170.0/24 can never be listed: AWS, GCP, Azure,
* OpenStack and the container runtimes on top of them serve instance metadata and
* credentials there, which is what the link-local block is for. An entry is taken
* only in the form a resolver answers with (no leading zeros, no ranges), because
* it is compared as a string with the resolved address.
*
* `invalid` lists what was refused, for the boot check; `ips` is what may be used,
* so a bad entry allows nothing.
*/
export function parseLinkLocalAllowList(raw: string | undefined): { ips: string[]; invalid: string[] } {
const ips: string[] = [];
const invalid: string[] = [];
for (const entry of (raw ?? '').split(',').map((e) => e.trim()).filter(Boolean)) {
const m = /^169\.254\.(\d{1,3})\.(\d{1,3})$/.exec(entry);
const canonical = m !== null && [m[1], m[2]].every((o) => String(Number(o)) === o && Number(o) <= 255);
if (canonical && m[1] !== '169' && m[1] !== '170') ips.push(entry);
else invalid.push(entry);
}
return { ips, invalid };
}
+14 -4
View File
@@ -5,6 +5,10 @@ import { embeddedTransitionIpv4, expandIpv6 } from './ipv6';
// Frozen at import on purpose (legacy timing; tests reload the module to change it).
const ALLOW_INTERNAL_NETWORK = readEnv().net.allowInternalNetwork;
// Link-local addresses an admin listed in ALLOW_LINK_LOCAL_IPS, a rootless Podman
// host gateway being the reason (#2400). The parser never lets a cloud metadata
// address in, so the rest of 169.254.0.0/16 stays blocked. Frozen the same way.
const ALLOWED_LINK_LOCAL = new Set(readEnv().net.allowLinkLocalIps);
export interface SsrfResult {
allowed: boolean;
@@ -27,7 +31,8 @@ function mappedIpv4(hextets: number[]): string | null {
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
}
// Always blocked — no override possible
// Blocked whatever ALLOW_INTERNAL_NETWORK says. The only way past is naming a single
// link-local address in ALLOW_LINK_LOCAL_IPS; loopback and metadata have none.
function isAlwaysBlocked(ip: string): boolean {
// Strip IPv6 brackets
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
@@ -36,8 +41,9 @@ function isAlwaysBlocked(ip: string): boolean {
if (addr.startsWith('127.') || addr === '::1') return true;
// Unspecified
if (addr.startsWith('0.')) return true;
// Link-local / cloud metadata
if (addr.startsWith('169.254.')) 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;
const hextets = expandIpv6(addr);
if (hextets) {
@@ -63,6 +69,9 @@ function isAlwaysBlocked(ip: string): boolean {
function isPrivateNetwork(ip: string): boolean {
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// A listed link-local address is a host on this machine's own network, so it
// needs ALLOW_INTERNAL_NETWORK here like any other.
if (ALLOWED_LINK_LOCAL.has(addr)) return true;
// RFC-1918 private ranges
if (addr.startsWith('10.')) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
@@ -142,7 +151,8 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
function isLinkLocal(ip: string): boolean {
const addr = (ip.startsWith('[') ? ip.slice(1, -1) : ip).toLowerCase();
// IPv4 link-local — AWS, GCP, Azure and OpenStack all serve credentials from 169.254.169.254.
if (addr.startsWith('169.254.')) return true;
// 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.
@@ -254,6 +254,11 @@ describe('deriveNet', () => {
expect(deriveNet({ ALLOW_INTERNAL_NETWORK: '1' }).allowInternalNetwork).toBe(true);
expect(deriveNet({}).allowInternalNetwork).toBe(false);
});
it('ALLOW_LINK_LOCAL_IPS keeps only the addresses that may be used', () => {
expect(deriveNet({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2,169.254.169.254,bogus' }).allowLinkLocalIps).toEqual(['169.254.1.2']);
expect(deriveNet({}).allowLinkLocalIps).toEqual([]);
});
});
describe('derivePaths', () => {
@@ -7,6 +7,7 @@ import {
numberOr,
parseBool,
parseDurationMs,
parseLinkLocalAllowList,
positiveIntOr,
positiveNumberOr,
resolveKeepaliveMs,
@@ -155,3 +156,23 @@ describe('resolveKeepaliveMs', () => {
expect(resolveKeepaliveMs('abc')).toBe(25_000);
});
});
describe('parseLinkLocalAllowList (ALLOW_LINK_LOCAL_IPS)', () => {
it('takes single link-local addresses, trimmed, and ignores empty entries', () => {
expect(parseLinkLocalAllowList(' 169.254.1.2 , ,169.254.0.1')).toEqual({ ips: ['169.254.1.2', '169.254.0.1'], invalid: [] });
expect(parseLinkLocalAllowList(undefined)).toEqual({ ips: [], invalid: [] });
expect(parseLinkLocalAllowList('')).toEqual({ ips: [], invalid: [] });
});
it('never takes the blocks where clouds serve metadata and credentials', () => {
const r = parseLinkLocalAllowList('169.254.169.254,169.254.170.2,169.254.169.123,169.254.1.2');
expect(r.ips).toEqual(['169.254.1.2']);
expect(r.invalid).toEqual(['169.254.169.254', '169.254.170.2', '169.254.169.123']);
});
it('refuses anything that is not one link-local IPv4 in the form a resolver answers with', () => {
for (const bad of ['10.0.0.1', '169.254.1', '169.254.1.2/32', '169.254.01.2', '169.254.1.256', 'fe80::1', 'host.example']) {
expect(parseLinkLocalAllowList(bad)).toEqual({ ips: [], invalid: [bad] });
}
});
});
@@ -49,6 +49,15 @@ describe('validateEnvAtBoot', () => {
expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '' })).not.toThrow();
});
it('refuses an ALLOW_LINK_LOCAL_IPS entry that is not a listable link-local address', () => {
// A metadata address would be dropped silently at runtime; saying so at boot
// is the difference between an OIDC login that works and a puzzling one.
expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '169.254.169.254' })).toThrow();
expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2,192.168.1.2' })).toThrow();
expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2' })).not.toThrow();
expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '' })).not.toThrow();
});
it('treats blank values as unset (defaults apply, no error)', () => {
expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow();
});
+74
View File
@@ -725,3 +725,77 @@ describe('safeFetchLlm', () => {
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
describe('ALLOW_LINK_LOCAL_IPS (#2400)', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
// The list is read when the module loads, like ALLOW_INTERNAL_NETWORK, so each
// case loads a fresh copy under its own environment.
async function guardWith(env: Record<string, string>) {
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);
const resolve = (ip: string) => lookup.mockResolvedValue({ address: ip, family: ip.includes(':') ? 6 : 4 });
return { guard, resolve };
}
it('reaches a rootless Podman host gateway from OIDC once the address is listed', async () => {
const { guard, resolve } = await guardWith({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2' });
resolve('169.254.1.2');
const okFetch = vi.fn().mockResolvedValue({ status: 200, headers: new Headers() });
vi.stubGlobal('fetch', okFetch);
await guard.safeFetchAdminConfigured('https://keycloak.example.com/realms/trek/.well-known/openid-configuration');
expect(okFetch).toHaveBeenCalledTimes(1);
});
it('refuses the gateway as before while nothing is listed, whatever ALLOW_INTERNAL_NETWORK says', async () => {
const { guard, resolve } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' });
resolve('169.254.1.2');
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
await expect(guard.safeFetchAdminConfigured('https://keycloak.example.com/')).rejects.toThrow(
'Requests to link-local / cloud-metadata addresses are not allowed',
);
expect((await guard.checkSsrf('https://keycloak.example.com/')).allowed).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('keeps every address that is not listed blocked, the metadata service included', async () => {
const { guard, resolve } = await guardWith({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2', ALLOW_INTERNAL_NETWORK: 'true' });
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
for (const ip of ['169.254.1.3', '169.254.169.254', '169.254.170.2', '::ffff:169.254.169.254']) {
resolve(ip);
await expect(guard.safeFetchAdminConfigured('https://idp.example/')).rejects.toThrow(guard.SsrfBlockedError);
expect((await guard.checkSsrf('https://idp.example/')).allowed).toBe(false);
}
expect(fetchSpy).not.toHaveBeenCalled();
});
it('counts as the internal network for a URL a user typed, so it also needs ALLOW_INTERNAL_NETWORK', async () => {
const closed = await guardWith({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2', ALLOW_INTERNAL_NETWORK: 'false' });
closed.resolve('169.254.1.2');
expect(await closed.guard.checkSsrf('https://immich.example/')).toMatchObject({
allowed: false,
isPrivate: true,
error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'),
});
const open = await guardWith({ ALLOW_LINK_LOCAL_IPS: '169.254.1.2', ALLOW_INTERNAL_NETWORK: 'true' });
open.resolve('169.254.1.2');
expect(await open.guard.checkSsrf('https://immich.example/')).toMatchObject({ allowed: true, isPrivate: true });
// A caller that refuses the internal network outright still refuses it.
expect((await open.guard.checkSsrf('https://immich.example/', true)).allowed).toBe(false);
// The IPv4-mapped spelling of the same address gets the same answer.
open.resolve('::ffff:169.254.1.2');
expect(await open.guard.checkSsrf('https://immich.example/')).toMatchObject({ allowed: true, isPrivate: true });
});
});