wacrm/src/lib/automations/engine.test.ts
Arnas Donauskas 7d1ddbfdb8 fix(security): SSRF guard on automation send_webhook action (GHSA-8jqh-598v-rfxc)
The send_webhook automation step fetched an account-controlled URL with
attacker-controlled headers and no SSRF protection, following redirects
by default — letting an agent+ reach cloud metadata (169.254.169.254),
loopback, and RFC1918 hosts from the server's network (status code
surfaces in automation logs). The separate webhook_endpoints delivery
path already guards this; the automation path did not.

Route send_webhook through the existing isDeliverableUrl guard before the
fetch, and mirror the deliver.ts hardening: redirect: 'manual' (a public
URL can't 3xx-bounce to an internal one) and a 10s timeout. Add an engine
regression test asserting a link-local target never reaches fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:04:11 +03:00

297 lines
9.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
// Shared mock state for the service-role client. Lives in a hoisted block
// so the vi.mock factory below can close over it.
const h = vi.hoisted(() => ({
state: {
owned: null as { id: string } | null,
ownedCustomField: null as { id: string } | null,
automations: [] as Record<string, unknown>[],
steps: [] as Record<string, unknown>[],
fromCalls: [] as string[],
updateCalls: [] as { table: string; filters: [string, string, unknown][] }[],
upsertCalls: [] as { table: string; payload: unknown }[],
},
}));
vi.mock("./admin-client", () => {
const { state } = h;
function resolve(ops: {
table: string;
type: string;
payload?: unknown;
filters: [string, string, unknown][];
}) {
const { table, type } = ops;
if (table === "contacts") {
if (type === "update") {
state.updateCalls.push({ table, filters: ops.filters });
return { data: null, error: null };
}
// ownership guard / condition read
return { data: state.owned, error: null };
}
if (table === "custom_fields") {
// account-scoped ownership lookup for a custom field definition
return { data: state.ownedCustomField, error: null };
}
if (table === "contact_custom_values") {
if (type === "upsert") {
state.upsertCalls.push({ table, payload: ops.payload });
return { data: null, error: null };
}
return { data: null, error: null };
}
if (table === "automations") return { data: state.automations, error: null };
if (table === "automation_logs") {
if (type === "insert") return { data: { id: "log1" }, error: null };
if (type === "update") return { data: null, error: null };
return { data: { steps_executed: [], status: "success" }, error: null };
}
if (table === "automation_steps") return { data: state.steps, error: null };
return { data: null, error: null };
}
function builder(table: string) {
const ops = {
table,
type: "select",
payload: undefined as unknown,
filters: [] as [string, string, unknown][],
};
const b: Record<string, unknown> = {
select: () => b,
insert: (p: unknown) => ((ops.type = "insert"), (ops.payload = p), b),
update: (p: unknown) => ((ops.type = "update"), (ops.payload = p), b),
delete: () => ((ops.type = "delete"), b),
upsert: (p: unknown) => ((ops.type = "upsert"), (ops.payload = p), b),
eq: (k: string, v: unknown) => (ops.filters.push(["eq", k, v]), b),
gte: () => b,
is: () => b,
order: () => b,
limit: () => b,
single: () => Promise.resolve(resolve(ops)),
maybeSingle: () => Promise.resolve(resolve(ops)),
then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) =>
Promise.resolve(resolve(ops)).then(onF, onR),
};
return b;
}
return {
supabaseAdmin: () => ({
from: (t: string) => {
state.fromCalls.push(t);
return builder(t);
},
rpc: () => Promise.resolve({ error: null }),
}),
};
});
vi.mock("./meta-send", () => ({
engineSendText: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
engineSendTemplate: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
}));
import { runAutomationsForTrigger } from "./engine";
const ACCOUNT = "acct-1";
beforeEach(() => {
h.state.owned = null;
h.state.ownedCustomField = null;
h.state.automations = [];
h.state.steps = [];
h.state.fromCalls = [];
h.state.updateCalls = [];
h.state.upsertCalls = [];
});
describe("runAutomationsForTrigger — tenant isolation", () => {
it("refuses to dispatch when the contact is not in the account (GHSA-63cv-2c49-m5v3)", async () => {
// Ownership lookup returns nothing — the contact belongs to another tenant.
h.state.owned = null;
// If the guard failed, this automation would run an update_contact_field step.
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [updateStep()];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "victim-contact-uuid",
context: { message_text: "manual trigger" },
});
// Bailed at the guard: never fetched automations, never wrote a contact.
expect(h.state.fromCalls).toContain("contacts");
expect(h.state.fromCalls).not.toContain("automations");
expect(h.state.updateCalls).toHaveLength(0);
});
it("proceeds past the guard when the contact belongs to the account", async () => {
h.state.owned = { id: "c1" };
h.state.automations = []; // no matching automations; just prove we got past the guard
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
expect(h.state.fromCalls).toContain("automations");
});
it("scopes the update_contact_field write to the automation's account", async () => {
h.state.owned = { id: "c1" };
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [updateStep()];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
expect(h.state.updateCalls).toHaveLength(1);
const filters = h.state.updateCalls[0].filters;
expect(filters).toContainEqual(["eq", "id", "c1"]);
expect(filters).toContainEqual(["eq", "account_id", ACCOUNT]);
});
});
describe("update_contact_field — custom fields", () => {
it("upserts contact_custom_values when the field is account-owned", async () => {
h.state.owned = { id: "c1" };
h.state.ownedCustomField = { id: "cf1" };
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [customStep("custom:cf1", "Premium")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
// No direct contacts column write for a custom field.
expect(h.state.updateCalls).toHaveLength(0);
expect(h.state.upsertCalls).toHaveLength(1);
expect(h.state.upsertCalls[0].payload).toEqual({
contact_id: "c1",
custom_field_id: "cf1",
value: "Premium",
});
});
it("interpolates {{ vars.* }} into the custom value", async () => {
h.state.owned = { id: "c1" };
h.state.ownedCustomField = { id: "cf1" };
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [customStep("custom:cf1", "{{ vars.source }}")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: { vars: { source: "WhatsApp Ad" } },
});
expect(h.state.upsertCalls).toHaveLength(1);
expect(
(h.state.upsertCalls[0].payload as { value: string }).value,
).toBe("WhatsApp Ad");
});
it("refuses to write a custom field from another account", async () => {
h.state.owned = { id: "c1" };
h.state.ownedCustomField = null; // account-scoped lookup finds nothing
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [customStep("custom:foreign-cf", "x")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
expect(h.state.upsertCalls).toHaveLength(0);
expect(h.state.updateCalls).toHaveLength(0);
});
});
describe("send_webhook — SSRF guard (GHSA-8jqh-598v-rfxc)", () => {
it("refuses a private / link-local destination and never calls fetch", async () => {
const fetchSpy = vi.fn(async () => ({ ok: true, status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
h.state.owned = { id: "c1" };
h.state.automations = [automationWithUpdateStep()];
// Aimed at the cloud metadata endpoint — the classic SSRF target.
h.state.steps = [webhookStep("http://169.254.169.254/latest/meta-data/")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
// The automation matched and its steps were loaded (so we genuinely
// reached the send_webhook case)...
expect(h.state.fromCalls).toContain("automation_steps");
// ...yet the guard blocked it before any outbound request left the box.
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
function webhookStep(url: string) {
return {
id: "s1",
automation_id: "a1",
step_type: "send_webhook",
position: 0,
parent_step_id: null,
step_config: { url, headers: { "Metadata-Flavor": "Google" }, body_template: "{}" },
};
}
function automationWithUpdateStep() {
return {
id: "a1",
account_id: ACCOUNT,
user_id: "u1",
trigger_type: "new_message_received",
trigger_config: {},
is_active: true,
};
}
function updateStep() {
return {
id: "s1",
automation_id: "a1",
step_type: "update_contact_field",
position: 0,
parent_step_id: null,
step_config: { field: "company", value: "pwned-by-automation" },
};
}
function customStep(field: string, value: string) {
return {
id: "s1",
automation_id: "a1",
step_type: "update_contact_field",
position: 0,
parent_step_id: null,
step_config: { field, value },
};
}