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."); }); });