Files
Nexus/packages/api/src/__tests__/webhook-dispatcher.test.ts
T
HartmutandClaude Opus 4.7 4ff7bc90c3 security: SSRF guard covers IPv6 + DNS-rebind defence via pinned IP (#49)
Expand the SSRF blocklist from IPv4-only to IPv6 loopback/ULA (fc00::/7)/
link-local (fe80::/10)/multicast/IPv4-mapped, plus the missing IPv4 ranges
0.0.0.0/8, 100.64.0.0/10 CGNAT, and TEST-NET/benchmark ranges. Replace the
single-lookup SSRF guard with resolveAndValidate(): resolves all DNS records
(lookup { all: true }) so a hostname returning "public + private" is
rejected, and returns the first validated address for connection pinning.

The webhook dispatcher now switches from plain fetch() to https.request()
with a custom Agent.lookup that returns the pre-validated IP. A DNS rebind
between the guard check and the TCP connect() can no longer redirect the
dial to an internal address. Hostname still flows through for SNI and
certificate validation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 09:19:07 +02:00

209 lines
5.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
const { sendSlackNotification } = vi.hoisted(() => ({
sendSlackNotification: vi.fn(),
}));
vi.mock("../lib/slack-notify.js", () => ({
sendSlackNotification,
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
// Dispatcher now resolves+validates DNS before opening the HTTPS socket.
// Mock node:dns/promises so tests do not require real network.
vi.mock("node:dns/promises", () => ({
lookup: vi.fn(async (_hostname: string, _opts?: unknown) => [
{ address: "93.184.216.34", family: 4 },
]),
}));
// Mock node:https so we never open a real socket. The dispatcher calls
// https.request(opts, cb); we return a minimal EventEmitter-like stub.
const { httpsRequestMock } = vi.hoisted(() => ({
httpsRequestMock: vi.fn(),
}));
vi.mock("node:https", () => ({
Agent: vi.fn(() => ({})),
request: httpsRequestMock,
}));
describe("webhook dispatcher logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("logs dispatcher-level failures with event context", async () => {
const db = {
webhook: {
findMany: vi.fn().mockRejectedValue(new Error("database unavailable")),
},
};
dispatchWebhooks(db, "estimate.submitted", { id: "estimate_1" });
await vi.waitFor(() => {
expect(logger.error).toHaveBeenCalledWith(
{
err: expect.any(Error),
event: "estimate.submitted",
},
"Failed to dispatch webhooks",
);
});
});
it("logs webhook delivery failures with webhook metadata", async () => {
sendSlackNotification.mockRejectedValueOnce(new Error("slack down"));
const db = {
webhook: {
findMany: vi.fn().mockResolvedValue([
{
id: "wh_1",
name: "Ops Slack",
url: "https://hooks.slack.com/services/test",
secret: null,
events: ["project.created"],
},
]),
},
};
dispatchWebhooks(db, "project.created", { id: "project_1", name: "Project One" });
await vi.waitFor(() => {
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
event: "project.created",
webhookId: "wh_1",
webhookName: "Ops Slack",
webhookUrl: "https://hooks.slack.com/services/test",
},
"Webhook delivery failed",
);
});
});
it("treats non-2xx HTTP webhook responses as delivery failures", async () => {
// Stub https.request to deliver a 500 response synchronously via the
// response callback, so the dispatcher sees a non-2xx and logs a warn.
httpsRequestMock.mockImplementation(
(_opts: unknown, cb: (res: { statusCode: number; resume: () => void }) => void) => {
queueMicrotask(() => cb({ statusCode: 500, resume: () => {} }));
return {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
};
},
);
const db = {
webhook: {
findMany: vi.fn().mockResolvedValue([
{
id: "wh_http_1",
name: "Project Webhook",
url: "https://example.com/webhooks/project",
secret: "secret",
events: ["project.created"],
},
]),
},
};
dispatchWebhooks(db, "project.created", { id: "project_1", name: "Project One" });
await vi.waitFor(() => {
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
event: "project.created",
webhookId: "wh_http_1",
webhookName: "Project Webhook",
webhookUrl: "https://example.com/webhooks/project",
},
"Webhook delivery failed",
);
});
expect(httpsRequestMock).toHaveBeenCalledTimes(1);
// Verify the pinned IP was passed via the lookup override on the Agent.
const firstCall = httpsRequestMock.mock.calls[0]![0] as {
host: string;
servername: string;
agent: { lookup?: unknown };
};
expect(firstCall.host).toBe("example.com");
expect(firstCall.servername).toBe("example.com");
});
it("pins the validated IP via the HTTPS Agent.lookup override (DNS-rebind defence)", async () => {
const { Agent } = await import("node:https");
const AgentMock = vi.mocked(Agent);
AgentMock.mockClear();
httpsRequestMock.mockImplementation(
(_opts: unknown, cb: (res: { statusCode: number; resume: () => void }) => void) => {
queueMicrotask(() => cb({ statusCode: 204, resume: () => {} }));
return {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
};
},
);
const db = {
webhook: {
findMany: vi.fn().mockResolvedValue([
{
id: "wh_rebind_1",
name: "Pinned Webhook",
url: "https://example.com/hook",
secret: null,
events: ["project.created"],
},
]),
},
};
dispatchWebhooks(db, "project.created", { id: "p1" });
await vi.waitFor(() => expect(httpsRequestMock).toHaveBeenCalledTimes(1));
expect(AgentMock).toHaveBeenCalledTimes(1);
const agentOptions = AgentMock.mock.calls[0]![0] as {
lookup?: (
host: string,
opts: unknown,
cb: (err: null, addr: string, family: number) => void,
) => void;
};
expect(typeof agentOptions.lookup).toBe("function");
// Invoke the lookup override to confirm it returns the pre-validated IP,
// NOT whatever DNS might be returning right now.
const cb = vi.fn();
agentOptions.lookup!("example.com", {}, cb);
expect(cb).toHaveBeenCalledWith(null, "93.184.216.34", 4);
});
});