mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
297 lines
9.3 KiB
TypeScript
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 },
|
|
};
|
|
}
|