From 7d1ddbfdb8296058ab787f7c57b8943c0214d14d Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 10:04:11 +0300 Subject: [PATCH] fix(security): SSRF guard on automation send_webhook action (GHSA-8jqh-598v-rfxc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/automations/engine.test.ts | 38 ++++++++++++++++++++++++++++++ src/lib/automations/engine.ts | 13 ++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/lib/automations/engine.test.ts b/src/lib/automations/engine.test.ts index e9617fc..e7b8fe2 100644 --- a/src/lib/automations/engine.test.ts +++ b/src/lib/automations/engine.test.ts @@ -224,6 +224,44 @@ describe("update_contact_field — custom fields", () => { }); }); +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", diff --git a/src/lib/automations/engine.ts b/src/lib/automations/engine.ts index c21a25c..535b1f6 100644 --- a/src/lib/automations/engine.ts +++ b/src/lib/automations/engine.ts @@ -16,6 +16,7 @@ import type { } from '@/types' import { supabaseAdmin } from './admin-client' import { engineSendText, engineSendTemplate } from './meta-send' +import { isDeliverableUrl } from '@/lib/webhooks/ssrf' // ------------------------------------------------------------ // Public API @@ -527,11 +528,23 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise case 'send_webhook': { const cfg = step.step_config as SendWebhookStepConfig if (!cfg.url) throw new Error('send_webhook needs url') + // SSRF guard: the URL and headers are account-controlled and the + // server makes the request, so refuse any destination that resolves + // to a private / loopback / link-local / reserved address. Mirrors + // the webhook_endpoints delivery path (see lib/webhooks/deliver.ts). + if (!(await isDeliverableUrl(cfg.url))) { + throw new Error('send_webhook: destination not allowed') + } const body = cfg.body_template ? interpolate(cfg.body_template, args) : JSON.stringify(args.context) const res = await fetch(cfg.url, { method: 'POST', headers: { 'content-type': 'application/json', ...(cfg.headers ?? {}) }, body, + // Do NOT follow redirects — a public URL could 3xx-bounce to an + // internal address, defeating the guard above. Bound the request + // so a hung/slow internal host can't tie up the runner. + redirect: 'manual', + signal: AbortSignal.timeout(10_000), }) if (!res.ok) throw new Error(`webhook returned ${res.status}`) return `webhook ${res.status}`