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:
@@ -49,6 +49,7 @@ See `values.yaml` for more options.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
|
||||
@@ -43,6 +43,9 @@ data:
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
|
||||
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ISSUER }}
|
||||
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -54,6 +54,9 @@ env:
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
# ALLOW_LINK_LOCAL_IPS: ""
|
||||
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
|
||||
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
|
||||
# OIDC_ISSUER: ""
|
||||
# OpenID Connect provider URL.
|
||||
# OIDC_CLIENT_ID: ""
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||
# - ALLOW_LINK_LOCAL_IPS= # Single link-local addresses TREK may reach after all, e.g. 169.254.1.2 for the host gateway of a rootless Podman container. Cloud metadata addresses cannot be listed.
|
||||
# - HTTP_PROXY=${HTTP_PROXY:-} # Optional. Proxy URL for outbound HTTP requests (e.g. http://192.168.1.10:7890)
|
||||
# - HTTPS_PROXY=${HTTPS_PROXY:-} # Optional. Proxy URL for outbound HTTPS requests
|
||||
# - NO_PROXY=${NO_PROXY:-localhost,127.0.0.1} # Optional. Comma-separated hosts and domains that bypass the outbound proxy
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,8 @@ like `NODE_ENV=staging` still boots.
|
||||
| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Used when **"Remember me" is unchecked** on the login form (the default): applies to the `trek_session` JWT `exp` claim, and the cookie is issued as a **browser-session cookie** (no `maxAge`, cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values abort startup — see Startup Validation above. Does not affect the short-lived MFA challenge token or MCP OAuth tokens (those keep their own TTL). | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length used when the user **ticks "Remember me"** on login: a longer-lived JWT `exp` claim plus a **persistent** `trek_session` cookie whose `maxAge` matches, so the session survives browser restarts. Same `ms`-style format and the same startup validation as `SESSION_DURATION`. | `30d` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich, a document store bound through [Document-Sync](Document-Sync) or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich, a document store bound through [Document-Sync](Document-Sync) or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless, apart from a link-local address listed in `ALLOW_LINK_LOCAL_IPS`. | `false` |
|
||||
| `ALLOW_LINK_LOCAL_IPS` | Single link-local addresses (`169.254.x.x`) TREK may reach after all, comma-separated. Meant for the host gateway of a rootless Podman container, `169.254.1.2`, with an identity provider or another service behind it. OIDC, AI Parsing, plugin OAuth and your own routing engines reach a listed address directly; the other integrations treat it as internal, so they also need `ALLOW_INTERNAL_NETWORK=true`. `169.254.169.x` and `169.254.170.x`, where cloud providers serve metadata and credentials, cannot be listed, and an entry that is not a single link-local IPv4 aborts startup. See [Internal-Network-Access](Internal-Network-Access#a-link-local-address-you-need). | unset |
|
||||
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links and subscribable calendar feed URLs (the `webcal://`/`https://` links the Subscribe dialog hands to Google/Apple/Outlook). | — |
|
||||
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships this wiki and serves it from disk, so the docs always match the version you are running. You should not need to set this — it is an escape hatch for unusual layouts. If the directory cannot be found, Help falls back to fetching the repository's `wiki/` folder from the `main` branch on GitHub (which can be ahead of the release you are running, and needs outbound network access). | the bundled `wiki/` directory |
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ TREK has two SSRF guards, both in `ssrfGuard.ts`. Which one applies depends on t
|
||||
|
||||
**The strict guard** (`safeFetch` / `safeFetchFollow`, built on `checkSsrf`) covers most outbound traffic: Immich, Synology Photos, AirTrail, Dawarich, the document stores behind [Document-Sync](Document-Sync) (Paperless-ngx, Papra, Nextcloud, OpenCloud and Synology), notification webhooks, ntfy, Unsplash, and place lookups. It resolves the hostname to an IP address before allowing the connection and blocks loopback, link-local and private ranges. Only the private ranges open up, and only with `ALLOW_INTERNAL_NETWORK=true`. The two tables below describe this guard.
|
||||
|
||||
**The relaxed guard** (`safeFetchAdminConfigured`, also exported as `safeFetchLlm`) covers endpoints that are expected to live on your own network: OIDC (discovery, token, userinfo, JWKS), the LLM providers behind the AI Parsing addon (a local Ollama or any OpenAI-compatible endpoint), and plugin OAuth token exchanges. The self-hosted routing engines an admin sets for the [Road-Trip](Road-Trip) addon (**Own routing engine**, **Own Valhalla instance**) go through it too when the server asks them itself, which it does for the MCP road trip tools. The planner asks them from the browser, so they must also be reachable from your users' devices. It deliberately permits loopback and LAN targets, so a model server on `localhost` or an identity provider on your LAN works **without** `ALLOW_INTERNAL_NETWORK`. It still resolves every hostname, re-checks every redirect hop, and always blocks link-local and cloud-metadata addresses (`169.254.0.0/16`, the full `fe80::/10`, and the AWS and Alibaba metadata addresses).
|
||||
**The relaxed guard** (`safeFetchAdminConfigured`, also exported as `safeFetchLlm`) covers endpoints that are expected to live on your own network: OIDC (discovery, token, userinfo, JWKS), the LLM providers behind the AI Parsing addon (a local Ollama or any OpenAI-compatible endpoint), and plugin OAuth token exchanges. The self-hosted routing engines an admin sets for the [Road-Trip](Road-Trip) addon (**Own routing engine**, **Own Valhalla instance**) go through it too when the server asks them itself, which it does for the MCP road trip tools. The planner asks them from the browser, so they must also be reachable from your users' devices. It deliberately permits loopback and LAN targets, so a model server on `localhost` or an identity provider on your LAN works **without** `ALLOW_INTERNAL_NETWORK`. It still resolves every hostname, re-checks every redirect hop, and always blocks link-local and cloud-metadata addresses (`169.254.0.0/16`, the full `fe80::/10`, and the AWS and Alibaba metadata addresses). The only way past is a single address listed in `ALLOW_LINK_LOCAL_IPS`, see [below](#a-link-local-address-you-need).
|
||||
|
||||
**No guard** applies to the addresses an admin sets in the environment for place search: `TREK_PLACES_URL`, `NOMINATIM_URL` and `OVERPASS_URL`. They are configuration rather than user input, so a self-run copy of the [TREK Places API](TREK-Places-API), a Nominatim or an Overpass instance on your LAN or in the same Docker network is reached without `ALLOW_INTERNAL_NETWORK`.
|
||||
|
||||
## Always blocked (no override possible)
|
||||
## Always blocked
|
||||
|
||||
Under the strict guard, these ranges are blocked regardless of any setting:
|
||||
Under the strict guard, these ranges are blocked whatever `ALLOW_INTERNAL_NETWORK` says:
|
||||
|
||||
| Range | Description |
|
||||
|---|---|
|
||||
@@ -25,6 +25,8 @@ Under the strict guard, these ranges are blocked regardless of any setting:
|
||||
|
||||
The IPv6 link-local rule here matches the `fe80:` hextet only, which is narrower than the nominal `fe80::/10` prefix (`fe80:` to `febf:`). In practice that is the same set of addresses, since RFC 4291 link-local addresses are always `fe80::/64`. The relaxed guard covers the whole `/10`.
|
||||
|
||||
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).
|
||||
|
||||
## Blocked unless `ALLOW_INTERNAL_NETWORK=true`
|
||||
|
||||
| Range / Hostname | Description |
|
||||
@@ -51,6 +53,22 @@ See [Environment-Variables](Environment-Variables) for how to set environment va
|
||||
|
||||
> **Admin:** Set `ALLOW_INTERNAL_NETWORK=true` in [Environment-Variables](Environment-Variables) before configuring Immich, Synology Photos, AirTrail, Dawarich or a document store on a LAN.
|
||||
|
||||
## A link-local address you need
|
||||
|
||||
A rootless Podman container reaches its host through `169.254.1.2`: `AddHost=keycloak.example.com:host-gateway` writes that address into the container's `/etc/hosts`. With the identity provider, or the reverse proxy in front of it, on the host, the OIDC login then fails with `[OIDC] Login error: Requests to link-local / cloud-metadata addresses are not allowed`, because both guards block `169.254.0.0/16`.
|
||||
|
||||
Name that one address in `ALLOW_LINK_LOCAL_IPS`:
|
||||
|
||||
```
|
||||
ALLOW_LINK_LOCAL_IPS=169.254.1.2
|
||||
```
|
||||
|
||||
- The relaxed guard (OIDC, AI Parsing, plugin OAuth, your own routing engines) then reaches it with nothing else set.
|
||||
- The strict guard treats it as internal, like a `192.168.x` address, so Immich, a document store or any other integration behind it also needs `ALLOW_INTERNAL_NETWORK=true`.
|
||||
- Every other link-local address stays blocked. `169.254.169.x` and `169.254.170.x`, where AWS, GCP, Azure and the container services built on them hand out instance metadata and credentials, cannot be listed at all. TREK refuses to start with one of them in the list, and with any entry that is not a single IPv4 address such as `169.254.1.2`, so IPv6 link-local (`fe80::/10`) cannot be listed either.
|
||||
|
||||
Several addresses are separated by commas. The list is read at startup, so restart TREK after changing it.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -243,7 +243,7 @@ Set `OIDC_ISSUER` to that exact string.
|
||||
|
||||
## OIDC login fails when provider is on a private/internal network
|
||||
|
||||
**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`.
|
||||
**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).
|
||||
|
||||
**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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user