From 7a314a92b15af9e112517471f314da559240f1cb Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 07:53:46 +0200 Subject: [PATCH] fix: add SSRF protection for link preview and Immich URL - Create server/src/utils/ssrfGuard.ts with checkSsrf() and createPinnedAgent() - Resolves DNS before allowing outbound requests to catch hostnames that map to private IPs (closes the TOCTOU gap in the old inline checks) - Always blocks loopback (127.x, ::1) and link-local/metadata (169.254.x) - RFC-1918, CGNAT (100.64/10), and IPv6 ULA ranges blocked by default; opt-in via ALLOW_INTERNAL_NETWORK=true for self-hosters running Immich on a local network - createPinnedAgent() pins node-fetch to the validated IP, preventing DNS rebinding between the check and the actual connection - Replace isValidImmichUrl() (hostname-string check, no DNS resolution) with checkSsrf(); make PUT /integrations/immich/settings async - Audit log entry (immich.private_ip_configured) written when a user saves an Immich URL that resolves to a private IP - Response includes a warning field surfaced as a toast in the UI - Replace ~20 lines of duplicated inline SSRF logic in the link-preview handler with a single checkSsrf() call + pinned agent - Document ALLOW_INTERNAL_NETWORK in README, docker-compose.yml, server/.env.example, chart/values.yaml, chart/templates/configmap.yaml, and chart/README.md --- README.md | 2 + chart/README.md | 1 + chart/templates/configmap.yaml | 3 + chart/values.yaml | 5 +- client/src/pages/SettingsPage.tsx | 3 +- docker-compose.yml | 1 + server/.env.example | 1 + server/src/routes/collab.ts | 29 ++----- server/src/routes/immich.ts | 59 ++++++++------- server/src/services/auditLog.ts | 4 + server/src/utils/ssrfGuard.ts | 122 ++++++++++++++++++++++++++++++ 11 files changed, 180 insertions(+), 50 deletions(-) create mode 100644 server/src/utils/ssrfGuard.ts diff --git a/README.md b/README.md index 7237f6c..003afa4 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ services: - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details + # - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich is on your local network (RFC-1918 IPs) volumes: - ./data:/app/data - ./uploads:/app/uploads @@ -251,6 +252,7 @@ trek.yourdomain.com { | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` | | `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` | +| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` | | **OIDC / SSO** | | | | `OIDC_ISSUER` | OpenID Connect provider URL | — | | `OIDC_CLIENT_ID` | OIDC client ID | — | diff --git a/chart/README.md b/chart/README.md index e6c7cb2..955e926 100644 --- a/chart/README.md +++ b/chart/README.md @@ -31,3 +31,4 @@ See `values.yaml` for more options. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Auto-generated and persisted to the data PVC if not provided. **Upgrading:** if a previous version used `JWT_SECRET`-derived encryption, set `secretEnv.ENCRYPTION_KEY` to your old `JWT_SECRET` value to keep existing encrypted data readable, then re-save credentials via the admin panel. - 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. diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 7a7ed6a..7e0a5a3 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -10,3 +10,6 @@ data: {{- if .Values.env.ALLOWED_ORIGINS }} ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} {{- end }} + {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} + ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} + {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 8613327..8c5968f 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -16,7 +16,10 @@ env: NODE_ENV: production PORT: 3000 # ALLOWED_ORIGINS: "" -# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + # 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. # Secret environment variables stored in a Kubernetes Secret. diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 36d29a1..70410d5 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -145,7 +145,8 @@ export default function SettingsPage(): React.ReactElement { const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { - await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + if (saveRes.data.warning) toast.warn(saveRes.data.warning) toast.success(t('memories.saved')) // Test connection const res = await apiClient.get('/integrations/immich/status') diff --git a/docker-compose.yml b/docker-compose.yml index bfa0092..42979a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy - TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP) + - 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. - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL - OIDC_CLIENT_ID=trek # OpenID Connect client ID - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret diff --git a/server/.env.example b/server/.env.example index 8396628..1dcabc0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,6 +11,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For +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. 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 diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index 98be75a..15f1779 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -9,6 +9,7 @@ import { broadcast } from '../websocket'; import { validateStringLengths } from '../middleware/validate'; import { checkPermission } from '../services/permissions'; import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; +import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; interface ReactionRow { emoji: string; @@ -513,35 +514,19 @@ router.get('/link-preview', authenticate, async (req: Request, res: Response) => try { const parsed = new URL(url); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return res.status(400).json({ error: 'Only HTTP(S) URLs are allowed' }); - } - const hostname = parsed.hostname.toLowerCase(); - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || - hostname === '0.0.0.0' || hostname.endsWith('.local') || hostname.endsWith('.internal') || - /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || - /^169\.254\./.test(hostname) || hostname === '[::1]' || hostname.startsWith('fc') || hostname.startsWith('fd') || hostname.startsWith('fe80')) { - return res.status(400).json({ error: 'Private/internal URLs are not allowed' }); - } - - const dns = require('dns').promises; - let resolved: { address: string }; - try { - resolved = await dns.lookup(parsed.hostname); - } catch { - return res.status(400).json({ error: 'Could not resolve hostname' }); - } - const ip = resolved.address; - if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|::1|::ffff:(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.))/.test(ip)) { - return res.status(400).json({ error: 'Private/internal URLs are not allowed' }); + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + return res.status(400).json({ error: ssrf.error }); } const nodeFetch = require('node-fetch'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - nodeFetch(url, { redirect: 'error', + nodeFetch(url, { + redirect: 'error', signal: controller.signal, + agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, }) .then((r: { ok: boolean; text: () => Promise }) => { diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 7b52e98..6e08f55 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -5,6 +5,8 @@ import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { checkSsrf } from '../utils/ssrfGuard'; +import { writeAudit, getClientIp } from '../services/auditLog'; const router = express.Router(); @@ -19,24 +21,6 @@ function isValidAssetId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; } -/** Validate that an Immich URL is a safe HTTP(S) URL (no internal/metadata IPs). */ -function isValidImmichUrl(raw: string): boolean { - try { - const url = new URL(raw); - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - const hostname = url.hostname.toLowerCase(); - // Block metadata endpoints and localhost - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false; - if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return false; - // Block link-local and loopback ranges - if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return false; - if (hostname.endsWith('.internal') || hostname.endsWith('.local')) return false; - return true; - } catch { - return false; - } -} - // ── Immich Connection Settings ────────────────────────────────────────────── router.get('/settings', authenticate, (req: Request, res: Response) => { @@ -48,17 +32,40 @@ router.get('/settings', authenticate, (req: Request, res: Response) => { }); }); -router.put('/settings', authenticate, (req: Request, res: Response) => { +router.put('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { immich_url, immich_api_key } = req.body; - if (immich_url && !isValidImmichUrl(immich_url.trim())) { - return res.status(400).json({ error: 'Invalid Immich URL. Must be a valid HTTP(S) URL.' }); + + if (immich_url) { + const ssrf = await checkSsrf(immich_url.trim()); + if (!ssrf.allowed) { + return res.status(400).json({ error: `Invalid Immich URL: ${ssrf.error}` }); + } + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + immich_url.trim(), + maybe_encrypt_api_key(immich_api_key), + authReq.user.id + ); + if (ssrf.isPrivate) { + writeAudit({ + userId: authReq.user.id, + action: 'immich.private_ip_configured', + ip: getClientIp(req), + details: { immich_url: immich_url.trim(), resolved_ip: ssrf.resolvedIp }, + }); + return res.json({ + success: true, + warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, + }); + } + } else { + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + null, + maybe_encrypt_api_key(immich_api_key), + authReq.user.id + ); } - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - immich_url?.trim() || null, - maybe_encrypt_api_key(immich_api_key), - authReq.user.id - ); + res.json({ success: true }); }); diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts index 527d514..03476ec 100644 --- a/server/src/services/auditLog.ts +++ b/server/src/services/auditLog.ts @@ -108,6 +108,7 @@ const ACTION_LABELS: Record = { 'admin.user_role_change': 'changed user role', 'admin.user_delete': 'deleted user', 'admin.invite_create': 'created invite', + 'immich.private_ip_configured': 'configured Immich with private IP', }; /** Best-effort; never throws — failures are logged only. */ @@ -158,6 +159,9 @@ function buildInfoSummary(action: string, details?: Record): st if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`); return parts.length ? ` (${parts.join(', ')})` : ''; } + if (action === 'immich.private_ip_configured') { + return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : ''; + } return ''; } diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts new file mode 100644 index 0000000..6882cdc --- /dev/null +++ b/server/src/utils/ssrfGuard.ts @@ -0,0 +1,122 @@ +import dns from 'dns/promises'; +import http from 'http'; +import https from 'https'; + +const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true'; + +export interface SsrfResult { + allowed: boolean; + resolvedIp?: string; + isPrivate: boolean; + error?: string; +} + +// Always blocked — no override possible +function isAlwaysBlocked(ip: string): boolean { + // Strip IPv6 brackets + const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip; + + // Loopback + if (/^127\./.test(addr) || addr === '::1') return true; + // Unspecified + if (/^0\./.test(addr)) return true; + // Link-local / cloud metadata + if (/^169\.254\./.test(addr) || /^fe80:/i.test(addr)) return true; + // IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x + if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true; + + return false; +} + +// Blocked unless ALLOW_INTERNAL_NETWORK=true +function isPrivateNetwork(ip: string): boolean { + const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip; + + // RFC-1918 private ranges + if (/^10\./.test(addr)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true; + if (/^192\.168\./.test(addr)) return true; + // CGNAT / Tailscale shared address space (100.64.0.0/10) + 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; + // IPv4-mapped RFC-1918 + if (/^::ffff:10\./i.test(addr)) return true; + if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true; + if (/^::ffff:192\.168\./i.test(addr)) return true; + + return false; +} + +function isInternalHostname(hostname: string): boolean { + const h = hostname.toLowerCase(); + return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost'; +} + +export async function checkSsrf(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { allowed: false, isPrivate: false, error: 'Invalid URL' }; + } + + if (!['http:', 'https:'].includes(url.protocol)) { + return { allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' }; + } + + const hostname = url.hostname.toLowerCase(); + + // Block internal hostname suffixes (no override — these are too easy to abuse) + if (isInternalHostname(hostname) && hostname !== 'localhost') { + return { allowed: false, isPrivate: false, error: 'Requests to .local/.internal domains are not allowed' }; + } + + // Resolve hostname to IP + let resolvedIp: string; + try { + const result = await dns.lookup(hostname); + resolvedIp = result.address; + } catch { + return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' }; + } + + if (isAlwaysBlocked(resolvedIp)) { + return { + allowed: false, + isPrivate: true, + resolvedIp, + error: 'Requests to loopback and link-local addresses are not allowed', + }; + } + + if (isPrivateNetwork(resolvedIp) || isInternalHostname(hostname)) { + if (!ALLOW_INTERNAL_NETWORK) { + return { + allowed: false, + isPrivate: true, + resolvedIp, + error: '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 }; + } + + return { allowed: true, isPrivate: false, resolvedIp }; +} + +/** + * Returns an http/https Agent whose `lookup` function is pinned to the + * already-validated IP. This prevents DNS rebinding (TOCTOU) by ensuring + * the outbound connection goes to the IP we checked, not a re-resolved one. + */ +export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent { + const options = { + lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, addr: string, family: number) => void) => { + // Determine address family from IP format + const family = resolvedIp.includes(':') ? 6 : 4; + callback(null, resolvedIp, family); + }, + }; + return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options); +}