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>
This commit is contained in:
@@ -19,6 +19,24 @@ vi.mock("../lib/logger.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// 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();
|
||||
@@ -82,11 +100,19 @@ describe("webhook dispatcher logging", () => {
|
||||
});
|
||||
|
||||
it("treats non-2xx HTTP webhook responses as delivery failures", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
// 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: {
|
||||
@@ -117,6 +143,66 @@ describe("webhook dispatcher logging", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user