mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #352 from ArnasDon/fix/ghsa-8jqh-598v-rfxc-webhook-ssrf
fix(security): SSRF guard on automation send_webhook action (GHSA-8jqh-598v-rfxc)
This commit is contained in:
commit
23838a9959
@ -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