/** * apps/worker — SEND_WEBHOOK executor (TASK-0112 §6). * * POST a (deep-interpolated) JSON payload to a PUBLIC https URL. Security: * - save-time URL shape is already guarded (https-only) by the shared schema; * - at runtime `assertPublicUrl` resolves the host's DNS and refuses any * private/link-local/loopback address (SSRF) — a FATAL SSRF_BLOCKED; * - redirects are followed manually and EVERY hop is re-checked with * `assertPublicUrl` (a redirect to an internal host is the classic SSRF * escape); * - the `Connection`/`Host` header set is minimal (no auth credentials can * leak to a third party). * * Classification (spec §4): 429/5xx → RETRYABLE (the pipeline re-runs the * whole run); 4xx → FATAL (a 4xx will not heal on retry); network error → * RETRYABLE. The response is recorded (status + first 4 KB of the body) in * the run output — never the full body (log hygiene). */ import { assertPublicUrl } from "@factory/shared"; import { RetryableError, FatalError } from "./classify.js"; import { interpolateDeep, runWithTimeout } from "./helpers.js"; import type { ActionExecutor, ActionResult } from "./types.js"; const MAX_REDIRECTS = 3; const TIMEOUT_MS = 30_000; const HOP_HEADERS = new Set([ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length" ]); function hopHeaders(headers: Record, location: URL): Record { const out: Record = {}; for (const [k, v] of Object.entries(headers)) { if (!HOP_HEADERS.has(k.toLowerCase())) out[k] = v; } out.host = location.host; return out; } async function post( url: string, headers: Record, body: string, redirectsLeft: number ): Promise<{ status: number; body: string }> { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...headers, accept: "application/json, */*" }, body, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) }); // Manual redirect follow with a per-hop SSRF re-check. if (res.status >= 300 && res.status < 400 && res.headers.get("location")) { if (redirectsLeft <= 0) throw new FatalError("WEBHOOK_TOO_MANY_REDIRECTS", `webhook exceeded ${MAX_REDIRECTS} redirects`); const next = new URL(res.headers.get("location")!, url); await res.body?.cancel(); await assertPublicUrl(next.toString()); return post(next.toString(), hopHeaders(headers, next), body, redirectsLeft - 1); } const text = await res.text(); return { status: res.status, body: text.slice(0, 4096) }; } const executor: ActionExecutor = { type: "SEND_WEBHOOK", async execute(cfg, ctx, deps): Promise { const warnings: string[] = []; const url = cfg.url as string; // Runtime SSRF guard (DNS-level) before any bytes go out. await assertPublicUrl(url); const headers = (cfg.headers ?? {}) as Record; const payloadObj = (cfg.payload ?? {}) as Record; const payload = JSON.stringify(interpolateDeep(payloadObj, ctx.vars, warnings)); const result = await runWithTimeout( TIMEOUT_MS, () => post(url, headers, payload, MAX_REDIRECTS) ); if (result.status === 429 || result.status >= 500) { throw new RetryableError("WEBHOOK_HTTP_RETRIABLE", `webhook responded ${result.status}`); } if (result.status >= 400) { throw new FatalError("WEBHOOK_HTTP_ERROR", `webhook responded ${result.status}: ${result.body.slice(0, 200)}`); } return { output: { url, status: result.status, response: result.body.slice(0, 1024) }, emittedEvents: [], audit: { action: "automation.webhook_sent", resourceType: "automation", resourceId: ctx.automationId, metadata: { url: new URL(url).hostname, status: result.status } } }; } }; export default executor;