decolua/9router · warning
err.message (SSRF guard: blocked internal/private/metadata U
Error message
err.message (SSRF guard: blocked internal/private/metadata URL)
What it means
Before fetching anything, the handler runs assertPublicUrl(targetUrl) from src/shared/utils/ssrfGuard.js to block SSRF (Server-Side Request Forgery) targets: localhost, private/link-local IPs (10.x, 192.168.x, 169.254.x, ::1), cloud metadata endpoints (169.254.169.254), and other non-public addresses. When the guard throws, the error message itself is returned as an HTTP 400 body, surfacing as 'err.message (SSRF guard: blocked internal/private/metadata URL)'.
Source
Thrown at src/sse/handlers/fetch.js:86
if (!targetUrl || typeof targetUrl !== "string") {
log.warn("FETCH", "Missing url");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: url");
}
// Validate URL format
try {
new URL(targetUrl);
} catch {
log.warn("FETCH", "Invalid URL", { url: targetUrl });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format");
}
// SSRF guard: reject internal/private/metadata targets
try {
assertPublicUrl(targetUrl);
} catch (err) {
log.warn("FETCH", "Blocked URL", { url: targetUrl });
return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message);
}
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
const combos = await getCombos();
const comboModels = getComboModelsFromData(providerInput, combos);
if (comboModels) {
const comboStrategies = settings.comboStrategies || {};
const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback";
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
log.info("FETCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings),
log,
comboName: providerInput,
comboStrategy,View on GitHub (pinned to 90b52e06ff)
Solutions
- Use a publicly routable http(s) URL - this guard is intentional and cannot be disabled per-request
- If you need internal pages, fetch them directly from your own code instead of routing through the gateway
- Check the returned err.message to see which class of address was blocked (loopback/private/metadata/scheme)
- If a legit public URL is blocked, verify DNS is not resolving it to a private IP (VPN/proxy/DNS override)
Example fix
// before
{ url: 'http://localhost:8080/api/page' }
// after
{ url: 'https://example.com/api/page' } Defensive patterns
Strategy: validation
Validate before calling
function isPublicHttpUrl(value) {
let u;
try { u = new URL(value); } catch { return false; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
const h = u.hostname;
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return false;
if (/^(10\.|127\.|169\.254\.|192\.168\.)/.test(h)) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return false;
if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80')) return false;
return true;
}
if (!isPublicHttpUrl(url)) throw new Error('URL targets a private/loopback address - rejected by SSRF guard'); Type guard
function isPublicHttpUrl(value) {
try { const u = new URL(value); return (u.protocol === 'http:' || u.protocol === 'https:') && !/^localhost$|^(127|10|169\.254|192\.168)\.|^172\.(1[6-9]|2\d|3[01])\.|^\[?::1\]?$/.test(u.hostname); } catch { return false; }
} Try / catch
const res = await fetch(endpoint, { method: 'POST', body: JSON.stringify({ model, url }) });
if (res.status === 400) {
const msg = await res.text();
if (/ssrf|internal|private|metadata/i.test(msg)) {
console.warn(`URL blocked by SSRF guard: ${url} (${msg})`); // do not retry - the block is intentional
return;
}
} Prevention
- Only send publicly routable https:// URLs to the gateway
- Never point the fetch endpoint at localhost/private IPs or cloud metadata (169.254.169.254) - the guard cannot be bypassed
- When fetching attacker-supplied URLs, expect this 400 and treat it as a security signal, not a bug
When it happens
Trigger: Requesting http://localhost:3000, http://127.0.0.1, http://192.168.1.1, http://169.254.169.254/latest/meta-data, file:// or other non-http(s) schemes, or any URL resolving to a private/reserved range.
Common situations: Pointing the fetcher at a local dev server instead of a public site; internal tooling that legitimately wanted intranet pages (unsupported by design); redirect-based attacks where an attacker supplies a URL that hops to metadata; CI environments where outbound traffic goes through a proxy on a private IP.
Related errors
- Blocked URL: internal host
- Invalid baseUrl: ${override}
- Invalid baseUrl protocol: ${parsed.protocol}
- `xai discovery ${field} host ${host} is not on x.ai`
- Blocked URL: private IP
AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30).
Data as JSON: /api/errors/7ea9df9477c8f263.
Report an issue: GitHub.