Files
OmniRoute/tests/unit/api/webhooks/webhook-url-ssrf-guard.test.ts
Diego Rodrigues de Sa e Souza 3d4f3e4960 test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) (#11968)
* test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966)

Two shards on release/v3.8.51 went red in one day with the same signature —
"ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from
combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only
.github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass
alone and on re-run: the cleanup races something still writing into the directory
(SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner
the window opens. 1154 test files do their own cleanup with
fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries.

One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record):
every rm / rmSync / rmdirSync option object with `recursive: true` and no
`maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries
ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292
files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included.
Only the option object changes: no call site, assertion or import is touched.

Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292
files; a random 20-file sample runs green (quota-redis-store hangs identically on
the untouched tree — it needs a Redis on localhost, an environment matter). The
four unit shards on this PR are the full run.

* fix(quality): let check-forgotten-sibling-tests read a 1,000-file diff

The gate shells out to `git diff` through execFileSync with Node's default 1 MB
maxBuffer; the 1,292-file codemod in this PR is the first diff large enough to
overflow it, and the gate died with `spawnSync git ENOBUFS` before comparing
anything. 64 MB is far above any real PR and costs nothing when unused.
2026-08-29 01:17:40 -03:00

168 lines
6.5 KiB
TypeScript

/**
* Tests for SSRF guard on the webhook URL surface:
*
* - POST /api/webhooks rejects custom/slack/discord webhooks targeting
* loopback / RFC1918 / link-local hosts.
* - PUT /api/webhooks/[id] rejects updates that would point a non-telegram
* webhook at a blocked host.
* - POST /api/webhooks/[id]/test refuses to perform the diagnostic fetch
* when the persisted URL is blocked (defense in depth, in case a row
* bypassed schema validation via direct DB access or older data).
* - kind === "telegram" is exempt — `url` there is a Telegram chat_id, not
* an HTTP URL — so the guard must not block it.
*/
import { describe, it, before, 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";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-webhook-ssrf-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.NODE_ENV = "test";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../../../src/lib/db/core.ts");
const { updateSettings } = await import("../../../../src/lib/db/settings.ts");
await updateSettings({ requireLogin: false });
const webhooksRoute = await import("../../../../src/app/api/webhooks/route.ts");
const webhookByIdRoute = await import("../../../../src/app/api/webhooks/[id]/route.ts");
const webhookTestRoute =
await import("../../../../src/app/api/webhooks/[id]/test/route.ts?suite=ssrf-guard");
const { createWebhook } = await import("../../../../src/lib/db/webhooks.ts");
after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
function jsonRequest(url: string, method: string, body: unknown): Request {
return new Request(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/webhooks — SSRF guard on create", () => {
it("rejects custom webhook pointing to 127.0.0.1", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "http://127.0.0.1:20128/api/v1/management/proxies",
kind: "custom",
});
const res = await webhooksRoute.POST(req);
assert.equal(res.status, 400, "loopback URL must be rejected");
const body = await res.json();
assert.ok(body.error, "error body must be present");
});
it("rejects slack webhook pointing to link-local metadata host", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "http://169.254.169.254/latest/meta-data/iam",
kind: "slack",
});
const res = await webhooksRoute.POST(req);
assert.equal(res.status, 400, "169.254/16 must be rejected");
});
it("rejects discord webhook pointing to RFC1918 (192.168/16)", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "http://192.168.0.15:8080/hook",
kind: "discord",
});
const res = await webhooksRoute.POST(req);
assert.equal(res.status, 400, "RFC1918 must be rejected");
});
it("rejects discord webhook with embedded credentials", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "https://user:pass@example.com/hook",
kind: "discord",
});
const res = await webhooksRoute.POST(req);
assert.equal(res.status, 400, "embedded credentials must be rejected");
});
it("rejects discord webhook with non-http(s) protocol", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "file:///etc/passwd",
kind: "discord",
});
const res = await webhooksRoute.POST(req);
assert.equal(res.status, 400, "non-http protocol must be rejected");
});
it("accepts public https webhook", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "https://example.com/hook",
kind: "custom",
});
const res = await webhooksRoute.POST(req);
assert.equal(res.status, 201, "public URL must be accepted");
});
it("does not trip the SSRF guard for telegram kind (url is a chat_id, not HTTP)", async () => {
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
url: "@my-telegram-channel",
kind: "telegram",
});
const res = await webhooksRoute.POST(req);
// Telegram may still be rejected for unrelated reasons (e.g., storage
// encryption not configured). What matters is that the rejection is NOT
// caused by the SSRF/URL guard refinement.
if (res.status === 400) {
const body = await res.json();
const msg = JSON.stringify(body).toLowerCase();
assert.ok(
!/block|private|invalid outbound url|outbound url/.test(msg),
`telegram chat_id must not trip the URL guard: ${msg}`
);
}
});
});
describe("PUT /api/webhooks/[id] — SSRF guard on update", () => {
it("rejects an update that flips a webhook URL to loopback", async () => {
const created = createWebhook({
url: "https://example.com/initial",
events: ["*"],
kind: "custom",
});
const req = jsonRequest(`http://localhost/api/webhooks/${created.id}`, "PUT", {
url: "http://localhost/internal",
});
const res = await webhookByIdRoute.PUT(req, {
params: Promise.resolve({ id: created.id }),
});
assert.equal(res.status, 400, "PUT must reject loopback host");
});
});
describe("POST /api/webhooks/[id]/test — defense in depth on dispatch", () => {
it("does not exfiltrate response body when persisted URL is loopback", async () => {
// Bypass schema by inserting directly via the DB layer.
const stale = createWebhook({
url: "http://127.0.0.1:1/should-be-blocked",
events: ["*"],
kind: "custom",
});
const req = new Request(`http://localhost/api/webhooks/${stale.id}/test`, { method: "POST" });
const res = await webhookTestRoute.POST(req, {
params: Promise.resolve({ id: stale.id }),
});
assert.equal(res.status, 200, "test endpoint still returns a structured diagnostic envelope");
const body = await res.json();
assert.equal(body.delivered, false, "blocked URL must not report a successful delivery");
assert.equal(body.status, 0, "no upstream status from a guarded call");
assert.equal(body.responseBody, "", "no upstream body must be exfiltrated");
assert.ok(
typeof body.error === "string" && /block|private|invalid/i.test(body.error),
`error message must signal the block: ${body.error}`
);
});
});