mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
This commit is contained in:
parent
1d7829e68a
commit
7d1ddbfdb8
@ -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",
|
||||
|
||||
@ -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}`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user