{"record":{"id":"d8454a4b017ba5aa","repo":"koala73/worldmonitor","slug":"webhook-url-is-not-a-valid-url","errorCode":null,"errorMessage":"Webhook URL is not a valid URL","messagePattern":"Webhook URL is not a valid URL","errorType":"validation","errorClass":"Error","httpStatus":400,"severity":"error","filePath":"api/_notification-webhook-ssrf.ts","lineNumber":239,"sourceCode":"async function defaultResolveHostname(hostname: string): Promise<string[]> {\n  const records = await Promise.all([\n    resolveDnsJson(hostname, 'A'),\n    resolveDnsJson(hostname, 'AAAA'),\n  ]);\n  return records.flat();\n}\n\n/**\n * Fail fast at registration when the webhook hostname currently resolves to a\n * private or reserved address. Delivery repeats this check (and pins its\n * connection) because DNS can change after registration.\n */\nexport async function assertNotificationWebhookRegistrationUrlSafe(\n  rawUrl: string,\n  resolveHostname: ResolveHostname = defaultResolveHostname,\n): Promise<void> {\n  const staticError = blockedNotificationWebhookUrlReason(rawUrl);\n  if (staticError) throw new Error(staticError);\n\n  const hostname = new URL(rawUrl).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(`Webhook URL DNS resolution failed: ${message}`);\n  }\n  if (!resolvedAddresses.length) throw new Error('Webhook URL DNS resolution returned no addresses');\n  if (resolvedAddresses.some(isBlockedNotificationResolvedAddress)) {\n    throw new Error('Webhook URL must not point to a private/local address');\n  }\n}\n","sourceCodeStart":221,"sourceCodeEnd":255,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/api/_notification-webhook-ssrf.ts#L221-L255","documentation":"Thrown when the widget-agent responded HTTP OK but res.body is null, so res.body.getReader() (line 332) could never run. A bodyless response means the endpoint returned something like 204, or the runtime stripped the ReadableStream: old browsers, proxies/CDNs that buffer responses, or service-worker fetch interception returning a synthetic Response. The chat modal needs a streaming body to read newline-delimited widget events, so it aborts with the same localized serverError template (the interpolated status will be the OK status, e.g. 200).","triggerScenarios":"Widget agent returned 204 No Content on some code path; a corporate proxy or CDN disabled streaming for the route; a polyfilled fetch whose Response lacks a ReadableStream body; an extension service worker intercepting fetch and returning new Response(null).","commonSituations":"Deploying the widget agent behind a proxy that does not support SSE; browser extensions intercepting fetch; jsdom/Node test environments where fetch semantics differ; an edge function branch returning new Response(null) instead of a stream.","solutions":["Check the Response in the Network tab: status, content-type, and whether a body streams at all","Verify the widget-agent route always returns a streaming body for chat POSTs, never 204 or new Response(null)","Retry in a clean browser profile with extensions/service workers disabled","If behind a proxy/CDN, disable response buffering for the widget-agent path (e.g., X-Accel-Buffering: no)"],"exampleFix":"// before\nif (!res.body) {\n  throw new Error(t('widgets.serverError', { status: res.status }));\n}\nconst reader = res.body.getReader();\n\n// after: distinguish 'no body' from 'server error' and fail with a precise message\nif (!res.body) {\n  throw new Error(`Widget agent returned ${res.status} with no stream body (proxy or 204?)`);\n}\nconst reader = res.body.getReader();","handlingStrategy":"type-guard","validationCode":"const streamSupportOk = typeof ReadableStream !== 'undefined' && typeof ReadableStream.prototype.getReader === 'function';\nif (!streamSupportOk) { fallbackToNonStreaming(); }","typeGuard":"function hasStreamBody(res: Response): res is Response & { body: ReadableStream<Uint8Array> } {\n  return res.body instanceof ReadableStream;\n}","tryCatchPattern":"const res = await fetch(widgetAgentUrl(), opts);\nif (!hasStreamBody(res)) {\n  throw new Error(`Widget agent returned ${res.status} without a stream body`);\n}","preventionTips":["Never return 204 or new Response(null) from streaming endpoints","Disable response buffering on proxies/CDNs in front of the widget agent","Test widget chat in a clean profile to catch extension service-worker interception","Assert res.body instanceof ReadableStream before calling getReader()"],"tags":["fetch","response-body","readable-stream","sse-stream","proxy"],"backgroundTag":"empty-response-body","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}