diff --git a/CHANGELOG.md b/CHANGELOG.md index d79bbce458..fe9debd383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ Thanks to everyone whose work landed in v3.8.12: - **sse/web-tools:** web-cookie providers (e.g. `ds-web`) that wrap tool calls as `{json}` are now parsed correctly — the real tool name is read from the JSON body instead of the tag attribute, and the call is no longer silently dropped when `arguments` is absent ([#3260](https://github.com/diegosouzapw/OmniRoute/issues/3260)) - **sse/groq:** non-reasoning Groq models (`llama-3.3-70b-versatile`, `llama-4-scout`) are now flagged `supportsReasoning: false`, so `reasoning_effort` / `output_config.effort` / `thinking` are stripped before dispatch instead of being forwarded and rejected with HTTP 400 — fixes the Claude Code → Groq regression of #764 ([#3258](https://github.com/diegosouzapw/OmniRoute/issues/3258)) - **api/images:** `POST /v1/images/edits` to a custom OpenAI-compatible provider no longer forwards an empty `model`. The multipart body is now built as a `Buffer` with an explicit boundary instead of a global `FormData` — the patched undici `fetch` serialized a native `FormData` as the literal string `[object FormData]` (text/plain), dropping every field including `model` ([#3273](https://github.com/diegosouzapw/OmniRoute/issues/3273)) -- **api/webhooks:** webhook URLs may now target a private/internal address (e.g. `192.168.x`, a docker-internal host) when `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true` — the webhook guard reuses the same explicit opt-in as private provider URLs (default OFF; protocol and embedded-credential checks stay unconditional) ([#3269](https://github.com/diegosouzapw/OmniRoute/issues/3269)) +- **api/webhooks:** webhook URLs may now target a private/internal address (e.g. `192.168.x`, a docker-internal host) when `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true` — the webhook guard reuses the same explicit opt-in as private provider URLs (default OFF; protocol and embedded-credential checks stay unconditional). Cloud-metadata / link-local endpoints (`169.254.169.254`, `metadata.google.internal`, `100.100.100.200`, `169.254.0.0/16`) are blocked **unconditionally** even with the opt-in on, and the webhook test endpoint redacts the upstream response body for private targets (no SSRF→IAM-credential pivot, no content exfiltration) ([#3269](https://github.com/diegosouzapw/OmniRoute/issues/3269)) --- diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index ecf1a6eada..6debd9a14a 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -15,6 +15,7 @@ import { insertDelivery } from "@/lib/db/webhookDeliveries"; import { recordWebhookDelivery } from "@/lib/localDb"; import { parseAndValidateWebhookUrl, + isPrivateHost, OutboundUrlGuardError, } from "@/shared/network/outboundUrlGuard"; import crypto from "crypto"; @@ -34,7 +35,11 @@ async function testFetch( }> { const start = Date.now(); try { - parseAndValidateWebhookUrl(url); + const parsed = parseAndValidateWebhookUrl(url); + // For private (opted-in) targets, return connectivity diagnostics only — never the + // upstream response body, so this endpoint can't be used to exfiltrate content from + // internal services reachable from the server. (#3269 hardening) + const redactBody = isPrivateHost(parsed.hostname); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10_000); const res = await fetch(url, { @@ -56,7 +61,12 @@ async function testFetch( } catch { rawBody = ""; } - return { success: res.ok, status: res.status, latencyMs, responseBody: rawBody }; + return { + success: res.ok, + status: res.status, + latencyMs, + responseBody: redactBody ? "" : rawBody, + }; } catch (error: any) { return { success: false, diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 59da1d83fa..41c108c5e9 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -81,6 +81,27 @@ export function isPrivateHost(hostname: string) { return false; } +const CLOUD_METADATA_HOSTNAMES = new Set([ + "169.254.169.254", // AWS / GCP / Azure / Oracle IMDS + "metadata.google.internal", // GCP + "metadata.goog", // GCP + "100.100.100.200", // Alibaba Cloud + "fd00:ec2::254", // AWS IPv6 IMDS +]); + +/** + * Cloud-metadata and IPv4 link-local (169.254.0.0/16) endpoints are the classic + * SSRF→IAM-credential pivot and have no legitimate webhook/automation use case. They are + * blocked UNCONDITIONALLY — even when private targets are explicitly opted in. (#3269) + */ +export function isCloudMetadataHost(hostname: string): boolean { + const host = normalizeHost(hostname); + if (!host) return false; + if (CLOUD_METADATA_HOSTNAMES.has(host)) return true; + if (host.startsWith("169.254.")) return true; // IPv4 link-local /16 + return false; +} + export function parseOutboundUrl(input: string | URL) { let url: URL; try { @@ -135,6 +156,16 @@ export function parseAndValidatePublicUrl(input: string | URL) { export function parseAndValidateWebhookUrl(input: string | URL) { const url = parseOutboundUrl(input); + // Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them + // even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269) + if (isCloudMetadataHost(url.hostname)) { + throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { + code: "OUTBOUND_URL_GUARD_BLOCKED", + url: url.toString(), + hostname: url.hostname || null, + }); + } + if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) { throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { code: "OUTBOUND_URL_GUARD_BLOCKED", diff --git a/tests/unit/webhook-metadata-guard-3269.test.ts b/tests/unit/webhook-metadata-guard-3269.test.ts new file mode 100644 index 0000000000..ef3dd6171e --- /dev/null +++ b/tests/unit/webhook-metadata-guard-3269.test.ts @@ -0,0 +1,77 @@ +/** + * Security hardening for #3269: even when private webhook targets are opted in via + * OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS, cloud-metadata / link-local endpoints + * (169.254.169.254, metadata.google.internal, 100.100.100.200, 169.254.0.0/16) must be + * blocked UNCONDITIONALLY — they are the classic SSRF→IAM-credential pivot and have no + * legitimate webhook use case. + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-wh-meta-3269-")); + +const { parseAndValidateWebhookUrl, isCloudMetadataHost, OutboundUrlGuardError } = await import( + "../../src/shared/network/outboundUrlGuard.ts" +); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +const FLAG = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS"; +const METADATA_TARGETS = [ + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "http://metadata.google.internal/computeMetadata/v1/", + "http://100.100.100.200/latest/meta-data/", + "http://169.254.1.2/anything", +]; + +describe("webhook guard blocks cloud-metadata unconditionally (#3269 hardening)", () => { + it("isCloudMetadataHost flags the known metadata + link-local hosts", () => { + assert.equal(isCloudMetadataHost("169.254.169.254"), true); + assert.equal(isCloudMetadataHost("metadata.google.internal"), true); + assert.equal(isCloudMetadataHost("100.100.100.200"), true); + assert.equal(isCloudMetadataHost("169.254.55.66"), true); + assert.equal(isCloudMetadataHost("10.0.0.5"), false); + assert.equal(isCloudMetadataHost("hooks.example.com"), false); + }); + + it("blocks metadata targets even when the private opt-in is ON", () => { + process.env[FLAG] = "true"; + try { + for (const target of METADATA_TARGETS) { + assert.throws( + () => parseAndValidateWebhookUrl(target), + OutboundUrlGuardError, + `expected ${target} to be blocked` + ); + } + } finally { + delete process.env[FLAG]; + } + }); + + it("still allows a normal private LAN host when opted in (not metadata)", () => { + process.env[FLAG] = "true"; + try { + const url = parseAndValidateWebhookUrl("http://192.168.0.10/hook"); + assert.equal(url.hostname, "192.168.0.10"); + } finally { + delete process.env[FLAG]; + } + }); +}); + +after(() => { + try { + resetDbInstance(); + } catch { + /* ignore */ + } + try { + fs.rmSync(process.env.DATA_DIR as string, { recursive: true, force: true }); + } catch { + /* ignore */ + } +});