Root cause
Validation & fail-open logic introduced
The new automation action accepted a webhook URL and custom headers, then fetched the destination from the server without the existing SSRF guard.
How AI contributed: Direct introductionPublished Jul 30, 2026 in
arnasdon/wacrmAll 2 findings in this repository
WACRM is a self-hostable CRM for WhatsApp, and in 0.7.0 and earlier its automation send_webhook action in src/lib/automations/engine.ts fetched any account-supplied URL from the server. The action took a webhook URL and custom headers straight to fetch, so an authenticated user with automation privileges could reach private, loopback, link-local, or cloud metadata endpoints. It skipped the isDeliverableUrl check in src/lib/webhooks/ssrf.ts that the webhook delivery path already applied. The fix routes the destination through that check, refuses redirects, and adds a ten-second timeout.
Claude-assisted automation code sent server-side POST requests to account-supplied URLs without the existing SSRF destination check. The direct fix blocks private and reserved targets and redirect bounces before the request.
Root cause
The new automation action accepted a webhook URL and custom headers, then fetched the destination from the server without the existing SSRF guard.
Fix
The direct PR member validates the resolved destination, refuses redirects, and adds a ten-second timeout at the same fetch sink.
Case facts
@@ -0,0 +396,12 @@ selected from new src/lib/automations/engine.ts+ case 'send_webhook': {+ const cfg = step.step_config as SendWebhookStepConfig+ if (!cfg.url) throw new Error('send_webhook needs url')+ 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,+ })+ if (!res.ok) throw new Error(`webhook returned ${res.status}`)+ return `webhook ${res.status}`+ }@@ -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<string> 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}`AI-assisted change 1aceacde866e96d3c3e7bed432fef4f3a66c54e63e5f15555320d1221dcec7e7 · Fix c3f5c0b0c45ffe3c5a8375fbabcad461087d6c4c51a8c9d8abf0f445589b39f8