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
This commit is contained in:
@@ -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<string> }) => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user