{"record":{"id":"c79ea4f60298f1b3","repo":"koala73/worldmonitor","slug":"callbackurl-resolves-to-a-private-reserved-address","errorCode":null,"errorMessage":"callbackUrl resolves to a private/reserved address: ${hostname}","messagePattern":"callbackUrl resolves to a private/reserved address: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":400,"severity":"warning","filePath":"server/worldmonitor/shipping/v2/webhook-shared.ts","lineNumber":121,"sourceCode":"    return (data.Answer ?? [])\n      .filter(answer => answer.type === expectedType && typeof answer.data === 'string')\n      .map(answer => answer.data!);\n  };\n  const records = await Promise.all([resolveRecordType('A'), resolveRecordType('AAAA')]);\n  return records.flat();\n}\n\n/**\n * Validate the current DNS answer before storing a webhook. Delivery makes the\n * same check immediately before send and pins the resulting socket, which\n * keeps this fail-fast check from becoming the only SSRF control.\n */\nexport async function assertCallbackUrlRegistrationSafe(\n  callbackUrl: string,\n  resolveHostname: ResolveHostname = defaultResolveHostname,\n): Promise<void> {\n  const staticError = isBlockedCallbackUrl(callbackUrl);\n  if (staticError) throw new Error(staticError);\n\n  const hostname = new URL(callbackUrl).hostname.toLowerCase();\n  if (isIpLiteral(hostname)) return;\n  let resolvedAddresses: string[];\n  try {\n    resolvedAddresses = await resolveHostname(hostname);\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    throw new Error(`callbackUrl DNS resolution failed: ${message}`);\n  }\n  if (!resolvedAddresses.length) throw new Error('callbackUrl DNS resolution returned no addresses');\n  const blocked = resolvedAddresses.find(isBlockedResolvedAddress);\n  if (blocked) throw new Error('callbackUrl resolves to a private/reserved address');\n}\n\nexport async function generateSecret(): Promise<string> {\n  const bytes = new Uint8Array(32);\n  crypto.getRandomValues(bytes);","sourceCodeStart":103,"sourceCodeEnd":139,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/server/worldmonitor/shipping/v2/webhook-shared.ts#L103-L139","documentation":"Static SSRF-policy check in isBlockedCallbackUrl (webhook-shared.ts:71-77, rethrown at :121): the hostname itself is an IP literal flagged by isBlockedResolvedAddress, or matches PRIVATE_HOSTNAME_PATTERNS — localhost, 127/8, 10/8, 192.168/16, 172.16-31, 169.254/16, IPv6 ULA fd00::/8, link-local fe80:, ::1, 0.0.0.0, 0/8, and CGNAT 100.64/10. The offending hostname is echoed in the message. (The separate post-DNS message at :134 omits the hostname.)","triggerScenarios":"POST RegisterWebhook whose callbackUrl host is a private/reserved IP literal (https://10.0.0.5/cb, https://[fd12::1]/cb) or the literal name 'localhost'. Because isIpLiteral() short-circuits at :124, IP-literal hosts are judged by this static check alone — no DNS resolution happens for them.","commonSituations":"Local development pointing at http://localhost:3000 or a LAN IP; Docker/Kubernetes internal addresses used as callbacks; staging configs with intranet IPs; quick smoke tests against 127.0.0.1.","solutions":["Use a public https hostname that resolves to a public address as the callback","For local testing, use an https tunnel to expose the local server under a public name","Strip private/literal hosts from configuration templates before they reach RegisterWebhook"],"exampleFix":"// before\nregisterWebhook(ctx, { callbackUrl: 'https://10.1.2.3/cb', chokepointIds });\n// after\nregisterWebhook(ctx, { callbackUrl: 'https://hooks.example.com/cb', chokepointIds });","handlingStrategy":"validation","validationCode":"const PRIVATE = [/^localhost$/i, /^127\\./, /^10\\./, /^192\\.168\\./, /^172\\.(1[6-9]|2\\d|3[01])\\./, /^169\\.254\\./, /^fd[0-9a-f]{2}:/i, /^fe80:/i, /^::1$/, /^0\\./, /^100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\./];\nconst h = new URL(callbackUrl).hostname.toLowerCase();\nif (PRIVATE.some(p => p.test(h))) throw new RangeError('private/reserved callback hosts are not allowed');","typeGuard":"const isPublicCallbackHost = (v: unknown): v is string => { if (typeof v !== 'string') return false; try { const h = new URL(v).hostname.toLowerCase(); return !PRIVATE_PATTERNS.some(p => p.test(h)) && !isPrivateIpLiteral(h); } catch { return false; } };","tryCatchPattern":"catch (e) { if (/private\\/reserved address/.test(e?.details?.[0]?.description ?? '')) { move the receiver to a public https hostname and re-submit } else throw e; }","preventionTips":["Never use localhost, LAN IPs, Docker/K8s internal addresses, or CGNAT ranges as callbacks","For local testing use an https tunnel with a public name","Keep private hosts out of shared configuration templates"],"tags":["ssrf","security","private-ip","webhooks","http-400","callback-url","rfc1918"],"backgroundTag":"ssrf-protection","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-08T15:18:49.778Z"}