mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Follow-up to the #3269 private-webhook opt-in. With the opt-in on, the private-host check was bypassed entirely, leaving cloud-metadata endpoints (169.254.169.254, metadata.google.internal, 100.100.100.200, link-local 169.254.0.0/16) reachable — the classic SSRF -> IAM-credential pivot — and the webhook test endpoint returned the upstream body, making it a content-exfiltration primitive against internal services. - outboundUrlGuard: add isCloudMetadataHost(); parseAndValidateWebhookUrl blocks those hosts UNCONDITIONALLY, even when private targets are opted in. - webhooks/[id]/test: redact responseBody for private targets (status + latency only). Regression test: tests/unit/webhook-metadata-guard-3269.test.ts (RED before, GREEN after); existing webhook SSRF/opt-in suites stay green (34/34).
This commit is contained in:
committed by
GitHub
parent
a5d19bf4b9
commit
36932b62a7
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 ? "<redacted: private target>" : rawBody,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -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",
|
||||
|
||||
77
tests/unit/webhook-metadata-guard-3269.test.ts
Normal file
77
tests/unit/webhook-metadata-guard-3269.test.ts
Normal file
@@ -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 */
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user