168 lines
6.5 KiB
TypeScript
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 });
|
|
});
|
|
|
|
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}`
|
|
);
|
|
});
|
|
});
|