{"record":{"id":"7ea9df9477c8f263","repo":"decolua/9router","slug":"err-message-ssrf-guard-blocked-internal-private","errorCode":null,"errorMessage":"err.message (SSRF guard: blocked internal/private/metadata URL)","messagePattern":"err\\.message \\(SSRF guard: blocked internal/private/metadata URL\\)","errorType":"validation","errorClass":null,"httpStatus":400,"severity":"warning","filePath":"src/sse/handlers/fetch.js","lineNumber":86,"sourceCode":"\n  if (!targetUrl || typeof targetUrl !== \"string\") {\n    log.warn(\"FETCH\", \"Missing url\");\n    return errorResponse(HTTP_STATUS.BAD_REQUEST, \"Missing required field: url\");\n  }\n\n  // Validate URL format\n  try {\n    new URL(targetUrl);\n  } catch {\n    log.warn(\"FETCH\", \"Invalid URL\", { url: targetUrl });\n    return errorResponse(HTTP_STATUS.BAD_REQUEST, \"Invalid URL format\");\n  }\n\n  // SSRF guard: reject internal/private/metadata targets\n  try {\n    assertPublicUrl(targetUrl);\n  } catch (err) {\n    log.warn(\"FETCH\", \"Blocked URL\", { url: targetUrl });\n    return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message);\n  }\n\n  // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers\n  const combos = await getCombos();\n  const comboModels = getComboModelsFromData(providerInput, combos);\n  if (comboModels) {\n    const comboStrategies = settings.comboStrategies || {};\n    const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || \"fallback\";\n    const comboStickyLimit = settings.comboStickyRoundRobinLimit;\n    log.info(\"FETCH\", `Combo \"${providerInput}\" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);\n    return handleComboChat({\n      body,\n      models: comboModels,\n      handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings),\n      log,\n      comboName: providerInput,\n      comboStrategy,","sourceCodeStart":68,"sourceCodeEnd":104,"githubUrl":"https://github.com/decolua/9router/blob/90b52e06ffd666b7929554211474d01588f6b1f8/src/sse/handlers/fetch.js#L68-L104","documentation":"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)'.","triggerScenarios":"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.","commonSituations":"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.","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)"],"exampleFix":"// before\n{ url: 'http://localhost:8080/api/page' }\n// after\n{ url: 'https://example.com/api/page' }","handlingStrategy":"validation","validationCode":"function isPublicHttpUrl(value) {\n  let u;\n  try { u = new URL(value); } catch { return false; }\n  if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;\n  const h = u.hostname;\n  if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return false;\n  if (/^(10\\.|127\\.|169\\.254\\.|192\\.168\\.)/.test(h)) return false;\n  if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(h)) return false;\n  if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80')) return false;\n  return true;\n}\nif (!isPublicHttpUrl(url)) throw new Error('URL targets a private/loopback address - rejected by SSRF guard');","typeGuard":"function isPublicHttpUrl(value) {\n  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; }\n}","tryCatchPattern":"const res = await fetch(endpoint, { method: 'POST', body: JSON.stringify({ model, url }) });\nif (res.status === 400) {\n  const msg = await res.text();\n  if (/ssrf|internal|private|metadata/i.test(msg)) {\n    console.warn(`URL blocked by SSRF guard: ${url} (${msg})`); // do not retry - the block is intentional\n    return;\n  }\n}","preventionTips":["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"],"tags":["ssrf","security","http-400","web-fetch","url-validation"],"backgroundTag":"ssrf-blocked-url","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}