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:
@@ -140,6 +140,7 @@ services:
|
|||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- 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)
|
- 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
|
- 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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
@@ -251,6 +252,7 @@ trek.yourdomain.com {
|
|||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
| `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 / SSO** | | |
|
||||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- 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.
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ data:
|
|||||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||||
|
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ env:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
# ALLOWED_ORIGINS: ""
|
# 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.
|
# Secret environment variables stored in a Kubernetes Secret.
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
const handleSaveImmich = async () => {
|
const handleSaveImmich = async () => {
|
||||||
setSaving(s => ({ ...s, immich: true }))
|
setSaving(s => ({ ...s, immich: true }))
|
||||||
try {
|
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'))
|
toast.success(t('memories.saved'))
|
||||||
// Test connection
|
// Test connection
|
||||||
const res = await apiClient.get('/integrations/immich/status')
|
const res = await apiClient.get('/integrations/immich/status')
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- 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
|
- 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)
|
- 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_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
- OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
- OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||||
- OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
- OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||||
|
|||||||
@@ -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
|
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||||
FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy
|
FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy
|
||||||
TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { broadcast } from '../websocket';
|
|||||||
import { validateStringLengths } from '../middleware/validate';
|
import { validateStringLengths } from '../middleware/validate';
|
||||||
import { checkPermission } from '../services/permissions';
|
import { checkPermission } from '../services/permissions';
|
||||||
import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
|
import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
|
||||||
|
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
|
||||||
|
|
||||||
interface ReactionRow {
|
interface ReactionRow {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
@@ -513,35 +514,19 @@ router.get('/link-preview', authenticate, async (req: Request, res: Response) =>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
const ssrf = await checkSsrf(url);
|
||||||
return res.status(400).json({ error: 'Only HTTP(S) URLs are allowed' });
|
if (!ssrf.allowed) {
|
||||||
}
|
return res.status(400).json({ error: ssrf.error });
|
||||||
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 nodeFetch = require('node-fetch');
|
const nodeFetch = require('node-fetch');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
nodeFetch(url, { redirect: 'error',
|
nodeFetch(url, {
|
||||||
|
redirect: 'error',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol),
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
|
||||||
})
|
})
|
||||||
.then((r: { ok: boolean; text: () => Promise<string> }) => {
|
.then((r: { ok: boolean; text: () => Promise<string> }) => {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { broadcast } from '../websocket';
|
|||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto';
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -19,24 +21,6 @@ function isValidAssetId(id: string): boolean {
|
|||||||
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
|
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 ──────────────────────────────────────────────
|
// ── Immich Connection Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
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 authReq = req as AuthRequest;
|
||||||
const { immich_url, immich_api_key } = req.body;
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
'admin.user_role_change': 'changed user role',
|
'admin.user_role_change': 'changed user role',
|
||||||
'admin.user_delete': 'deleted user',
|
'admin.user_delete': 'deleted user',
|
||||||
'admin.invite_create': 'created invite',
|
'admin.invite_create': 'created invite',
|
||||||
|
'immich.private_ip_configured': 'configured Immich with private IP',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Best-effort; never throws — failures are logged only. */
|
/** Best-effort; never throws — failures are logged only. */
|
||||||
@@ -158,6 +159,9 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
|
|||||||
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
|
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
|
||||||
return parts.length ? ` (${parts.join(', ')})` : '';
|
return parts.length ? ` (${parts.join(', ')})` : '';
|
||||||
}
|
}
|
||||||
|
if (action === 'immich.private_ip_configured') {
|
||||||
|
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
122
server/src/utils/ssrfGuard.ts
Normal file
122
server/src/utils/ssrfGuard.ts
Normal file
@@ -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<SsrfResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user