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>
209 lines
5.9 KiB
TypeScript
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);
|
|
});
|
|
});
|