Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tool_call name="...">{json}</tool_call>` 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))

---

Expand Down
14 changes: 12 additions & 2 deletions src/app/api/webhooks/[id]/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, {
Expand All @@ -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 ? "<redacted: private target>" : rawBody,
};
} catch (error: any) {
return {
success: false,
Expand Down
31 changes: 31 additions & 0 deletions src/shared/network/outboundUrlGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/webhook-metadata-guard-3269.test.ts
Original file line number Diff line number Diff line change
@@ -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 */
}
});