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:
Arnas Donauskas 2026-07-08 10:09:23 +03:00 committed by GitHub
commit 23838a9959
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 51 additions and 0 deletions

View File

@ -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",

View File

@ -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}`