203 lines
8.8 KiB
TypeScript
203 lines
8.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {
|
|
entityWebhook: {
|
|
deleteMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
entityWebhookLog: {
|
|
create: vi.fn(async (args) => args.data),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/url.server", () => ({
|
|
getBaseURL: vi.fn((request: Request) => new URL(request.url).origin),
|
|
}));
|
|
|
|
// The shared outbound client resolves DNS and rejects hosts that resolve to
|
|
// internal addresses. Stub the resolver so public test hosts resolve to a
|
|
// public IP, while a chosen host simulates DNS rebinding to a metadata address.
|
|
vi.mock("node:dns/promises", () => ({
|
|
lookup: vi.fn(async (hostname: string) => {
|
|
if (hostname === "rebind.attacker.test") {
|
|
return [{ address: "169.254.169.254", family: 4 }];
|
|
}
|
|
return [{ address: "93.184.216.34", family: 4 }];
|
|
}),
|
|
}));
|
|
|
|
import { db } from "~/utils/db.server";
|
|
import { callEntityWebhooks, deleteEntityWebhook, getEntityWebhook, updateEntityWebhook } from "./entityWebhooks.db.server";
|
|
|
|
const entityWebhookMock = vi.mocked(db.entityWebhook, true);
|
|
const entityWebhookLogMock = vi.mocked(db.entityWebhookLog, true);
|
|
|
|
describe("entity webhook delivery", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 204 })));
|
|
});
|
|
|
|
it("delivers allowed absolute webhook URLs", async () => {
|
|
entityWebhookMock.findMany.mockResolvedValueOnce([
|
|
{ id: "webhook_absolute", entityId: "entity_1", action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" },
|
|
]);
|
|
|
|
await callEntityWebhooks({
|
|
logId: "log_1",
|
|
entityId: "entity_1",
|
|
action: "created",
|
|
body: "payload",
|
|
request: new Request("https://tenant.example.com/api/rows"),
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith("https://hooks.example.com/rows", expect.objectContaining({ redirect: "manual", signal: expect.any(AbortSignal) }));
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_absolute", logId: "log_1", status: 204 } });
|
|
});
|
|
|
|
it("delivers webhooks at request-relative endpoints resolved against the base URL", async () => {
|
|
entityWebhookMock.findMany.mockResolvedValueOnce([
|
|
{ id: "webhook_relative", entityId: "entity_1", action: "created", method: "POST", endpoint: "/webhooks/rows" },
|
|
]);
|
|
|
|
await callEntityWebhooks({
|
|
logId: "log_1",
|
|
entityId: "entity_1",
|
|
action: "created",
|
|
body: "payload",
|
|
request: new Request("https://tenant.example.com/api/rows"),
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith("https://tenant.example.com/webhooks/rows", expect.objectContaining({ redirect: "manual", signal: expect.any(AbortSignal) }));
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_relative", logId: "log_1", status: 204 } });
|
|
});
|
|
|
|
it("follows safe redirects after validating the redirect target", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://hooks.example.com/rows-next" } }))
|
|
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
|
);
|
|
entityWebhookMock.findMany.mockResolvedValueOnce([
|
|
{ id: "webhook_redirect", entityId: "entity_1", action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" },
|
|
]);
|
|
|
|
await callEntityWebhooks({
|
|
logId: "log_1",
|
|
entityId: "entity_1",
|
|
action: "created",
|
|
body: "payload",
|
|
request: new Request("https://tenant.example.com/api/rows"),
|
|
});
|
|
|
|
expect(fetch).toHaveBeenNthCalledWith(1, "https://hooks.example.com/rows", expect.objectContaining({ method: "POST", redirect: "manual" }));
|
|
expect(fetch).toHaveBeenNthCalledWith(2, "https://hooks.example.com/rows-next", expect.objectContaining({ method: "POST", redirect: "manual" }));
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_redirect", logId: "log_1", status: 204 } });
|
|
});
|
|
|
|
it("blocks unsafe redirect targets without following them", async () => {
|
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/webhook" } })));
|
|
entityWebhookMock.findMany.mockResolvedValueOnce([
|
|
{ id: "webhook_redirect_blocked", entityId: "entity_1", action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" },
|
|
]);
|
|
|
|
await callEntityWebhooks({
|
|
logId: "log_1",
|
|
entityId: "entity_1",
|
|
action: "created",
|
|
body: "payload",
|
|
request: new Request("https://tenant.example.com/api/rows"),
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
expect(fetch).toHaveBeenCalledWith("https://hooks.example.com/rows", expect.objectContaining({ redirect: "manual" }));
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_redirect_blocked", logId: "log_1", status: 0 } });
|
|
});
|
|
|
|
it("blocks unsafe webhook URLs and records failed delivery status without fetching", async () => {
|
|
entityWebhookMock.findMany.mockResolvedValueOnce([
|
|
{ id: "webhook_private", entityId: "entity_1", action: "created", method: "POST", endpoint: "http://10.0.0.1/webhook" },
|
|
{ id: "webhook_internal", entityId: "entity_1", action: "created", method: "POST", endpoint: "https://worker.internal/webhook" },
|
|
{ id: "webhook_relative_localhost", entityId: "entity_1", action: "created", method: "POST", endpoint: "/webhooks/rows" },
|
|
]);
|
|
|
|
await callEntityWebhooks({
|
|
logId: "log_1",
|
|
entityId: "entity_1",
|
|
action: "created",
|
|
body: "payload",
|
|
request: new Request("https://localhost/api/rows"),
|
|
});
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_private", logId: "log_1", status: 0 } });
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_internal", logId: "log_1", status: 0 } });
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_relative_localhost", logId: "log_1", status: 0 } });
|
|
});
|
|
|
|
it("blocks public hostnames that resolve to internal addresses (DNS rebinding) without fetching", async () => {
|
|
entityWebhookMock.findMany.mockResolvedValueOnce([
|
|
{ id: "webhook_rebind", entityId: "entity_1", action: "created", method: "POST", endpoint: "https://rebind.attacker.test/webhook" },
|
|
]);
|
|
|
|
await callEntityWebhooks({
|
|
logId: "log_1",
|
|
entityId: "entity_1",
|
|
action: "created",
|
|
body: "payload",
|
|
request: new Request("https://tenant.example.com/api/rows"),
|
|
});
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(entityWebhookLogMock.create).toHaveBeenCalledWith({ data: { webhookId: "webhook_rebind", logId: "log_1", status: 0 } });
|
|
});
|
|
});
|
|
|
|
describe("entity webhook admin boundaries", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("reads a webhook only inside the current entity scope", async () => {
|
|
const webhook = { id: "webhook_1", entityId: "entity_1", action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" };
|
|
entityWebhookMock.findFirst.mockResolvedValueOnce(webhook);
|
|
|
|
await expect(getEntityWebhook({ id: "webhook_1", entityId: "entity_1" })).resolves.toEqual(webhook);
|
|
|
|
expect(entityWebhookMock.findFirst).toHaveBeenCalledWith({ where: { id: "webhook_1", entityId: "entity_1" } });
|
|
});
|
|
|
|
it("updates and deletes only webhooks inside the current entity scope", async () => {
|
|
entityWebhookMock.updateMany.mockResolvedValueOnce({ count: 1 });
|
|
entityWebhookMock.deleteMany.mockResolvedValueOnce({ count: 1 });
|
|
|
|
await updateEntityWebhook(
|
|
{ id: "webhook_1", entityId: "entity_1" },
|
|
{ action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" }
|
|
);
|
|
await deleteEntityWebhook({ id: "webhook_1", entityId: "entity_1" });
|
|
|
|
expect(entityWebhookMock.updateMany).toHaveBeenCalledWith({
|
|
where: { id: "webhook_1", entityId: "entity_1" },
|
|
data: { action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" },
|
|
});
|
|
expect(entityWebhookMock.deleteMany).toHaveBeenCalledWith({ where: { id: "webhook_1", entityId: "entity_1" } });
|
|
});
|
|
|
|
it("rejects scoped update and delete misses", async () => {
|
|
entityWebhookMock.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
entityWebhookMock.deleteMany.mockResolvedValueOnce({ count: 0 });
|
|
|
|
await expect(
|
|
updateEntityWebhook({ id: "webhook_foreign", entityId: "entity_1" }, { action: "created", method: "POST", endpoint: "https://hooks.example.com/rows" })
|
|
).rejects.toThrow("Entity webhook not found.");
|
|
await expect(deleteEntityWebhook({ id: "webhook_foreign", entityId: "entity_1" })).rejects.toThrow("Entity webhook not found.");
|
|
});
|
|
});
|