mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +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() {
|
function automationWithUpdateStep() {
|
||||||
return {
|
return {
|
||||||
id: "a1",
|
id: "a1",
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { supabaseAdmin } from './admin-client'
|
import { supabaseAdmin } from './admin-client'
|
||||||
import { engineSendText, engineSendTemplate } from './meta-send'
|
import { engineSendText, engineSendTemplate } from './meta-send'
|
||||||
|
import { isDeliverableUrl } from '@/lib/webhooks/ssrf'
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
@ -527,11 +528,23 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
|
|||||||
case 'send_webhook': {
|
case 'send_webhook': {
|
||||||
const cfg = step.step_config as SendWebhookStepConfig
|
const cfg = step.step_config as SendWebhookStepConfig
|
||||||
if (!cfg.url) throw new Error('send_webhook needs url')
|
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 body = cfg.body_template ? interpolate(cfg.body_template, args) : JSON.stringify(args.context)
|
||||||
const res = await fetch(cfg.url, {
|
const res = await fetch(cfg.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json', ...(cfg.headers ?? {}) },
|
headers: { 'content-type': 'application/json', ...(cfg.headers ?? {}) },
|
||||||
body,
|
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}`)
|
if (!res.ok) throw new Error(`webhook returned ${res.status}`)
|
||||||
return `webhook ${res.status}`
|
return `webhook ${res.status}`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user