mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user