/** * TASK-0110 §5 / TASK-0130 — public incoming-webhook endpoint. * * `POST /api/v1/webhooks/incoming/:webhookId` (NO auth — the signature IS the * auth): * 1. raw body captured by the scoped `express.raw` middleware (req.body = * Buffer; JSON-only, 256 KB cap → 413 over limit); * 2. unknown/inactive webhookId → 404 (no existence leak beyond the id); * 3. per-webhookId rate limit 60/min (Redis INCR + EXPIRE window) → 429; * 4. `X-Factory-Signature: t=,v1=hex(hmac_sha256(secret, t+"."+rawBody))` * (`X-FS-Signature` accepted as a legacy alias) — KMS-unwrap the stored * secret, verify constant-time, timestamp tolerance ±5 min → 401 * WEBHOOK_SIGNATURE_INVALID / WEBHOOK_TIMESTAMP_STALE; * 5. TASK-0130 REPLAY PROTECTION — a Redis nonce `sha256(t|body-hash)` with * a 10-min window (2× the ts tolerance); a re-sent identical request is * 401 WEBHOOK_REPLAY_DETECTED; * 6. success → **202** `{ data: { runId } }` + a PENDING AutomationRun + * an `INTEGRATION` audit row + enqueue `run-automation` (async). * * Header logging is minimal and never includes sensitive values * (authorization / cookie / x-fs-signature / x-factory-signature excluded). */ import { Body, Controller, Headers, HttpCode, Inject, Param, Post, Req } from "@nestjs/common"; import type { Request } from "express"; import { createHash } from "node:crypto"; import { RUN_INPUT_MAX_BYTES, WEBHOOK_RATE_PER_MINUTE, truncateJson, verifyWebhookSignature } from "@factory/shared"; import { v4 as uuid } from "uuid"; import { ApiError } from "../../common/errors.js"; import { AuditService } from "../../common/audit.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AutomationsService } from "./automations.service.js"; import { webhookRateKey } from "@factory/shared"; /** Replay nonce window: 2× the ±5 min timestamp tolerance. */ const REPLAY_WINDOW_SECONDS = 10 * 60; /** Header allow-list copied into the stored triggerEvent (non-sensitive only). */ const SAFE_HEADERS: Record = { "content-type": "content-type", "user-agent": "user-agent", "x-request-id": "x-request-id" }; @Controller("webhooks/incoming") export class WebhookIncomingController { constructor( @Inject(AutomationsService) private readonly automations: AutomationsService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService ) {} @Post(":webhookId") @HttpCode(202) async receive( @Param("webhookId") webhookId: string, @Headers() headers: Record, @Req() req: Request & { rawBody?: Buffer } ): Promise<{ data: { runId: string } }> { // 1. raw body — Nest's `rawBody: true` option sets `req.rawBody` to the // EXACT request bytes (body-parser `verify` hook). `req.body` is the // JSON-parsed object (re-serialized, NOT signature-matchable). const raw = req.rawBody; if (!Buffer.isBuffer(raw) || raw.length === 0) { throw ApiError.validation("Request body must be non-empty application/json."); } const rawBody = raw.toString("utf8"); // 2. resolve the ACTIVE webhook trigger (404-safe). const trigger = await this.automations.findActiveWebhook(webhookId); if (!trigger) throw ApiError.notFound("Webhook not found."); const cfg = trigger.config as Record; const encrypted = cfg.secretEncrypted; if (typeof encrypted !== "string" || !encrypted) throw ApiError.notFound("Webhook not found."); const automation = trigger.automation; // 3. rate limit (60/min per webhookId; Redis window). const rateKey = webhookRateKey(webhookId); const r = this.redis.getRedis(); const count = await r.incr(rateKey); if (count === 1) await r.expire(rateKey, 60); if (count > WEBHOOK_RATE_PER_MINUTE) { throw ApiError.webhookRateLimited(); } // 4. signature verification. TASK-0130: accept the canonical // X-Factory-Signature header; keep the legacy X-FS-Signature alias. const signatureHeader = headers["x-factory-signature"] ?? headers["X-Factory-Signature"] ?? headers["x-fs-signature"] ?? headers["X-FS-Signature"]; const secret = this.automations.unwrapSecret(encrypted); const verdict = verifyWebhookSignature(secret, signatureHeader, rawBody, Date.now()); if (verdict === "WEBHOOK_TIMESTAMP_STALE") throw ApiError.webhookTimestampStale(); if (verdict === "WEBHOOK_SIGNATURE_INVALID") throw ApiError.webhookSignatureInvalid(); // 5. TASK-0130 replay protection: the (timestamp, body) pair is a nonce. // A re-sent byte-identical request re-presents the same nonce → 401. const parts = verifyWebhookSignatureParts(signatureHeader); const bodyHash = createHash("sha256").update(rawBody).digest("hex"); const nonceKey = `webhook:replay:${webhookId}:${parts ? parts.t : "unknown"}:${bodyHash}`; const firstSeen = await r.set(nonceKey, "1", "EX", REPLAY_WINDOW_SECONDS, "NX"); if (firstSeen !== "OK") throw ApiError.webhookReplayDetected(); // 6. parse JSON (the express.raw middleware only accepted json content-type). let parsedBody: unknown; try { parsedBody = JSON.parse(rawBody); } catch { throw ApiError.validation("Webhook body must be valid JSON."); } const safeHeaders: Record = {}; for (const [key, headerName] of Object.entries(SAFE_HEADERS)) { const v = headers[key] ?? headers[headerName]; if (typeof v === "string") safeHeaders[headerName] = v; } const triggerEvent: Record = { type: "webhook", payload: parsedBody, headers: safeHeaders }; // 7. PENDING run + enqueue (run storage truncated to 64 KB). const run = await this.automations.createIncomingRun({ workspaceId: automation.workspaceId, automationId: automation.id, triggerEvent, input: truncateJson(triggerEvent, RUN_INPUT_MAX_BYTES).value }); await this.automations.enqueueRunPublic(run.id, automation.id, automation.workspaceId, triggerEvent, uuid()); // 8. TASK-0130 audit — the external integrator is the actor (INTEGRATION). await this.audit.audit(req, undefined, { actorType: "INTEGRATION", actorId: webhookId, workspaceId: automation.workspaceId, resourceType: "automation", resourceId: automation.id, action: "webhook.received", metadata: { runId: run.id } }); // 9. minimal header log (no cookies/auth/signature values ever). this.automations.logWebhookHit(webhookId, automation.id, run.id, safeHeaders); // TASK-0130 contract: 202 + the run id the client polls // (GET /workspaces/:wid/automations/:aid/runs/:runId). return { data: { runId: run.id } }; } } /** Re-parse the verified header to recover the timestamp for the nonce key. * Safe: the caller already validated the signature, so a missing/malformed * header can only reach here as "unknown" (treated as its own nonce class). */ function verifyWebhookSignatureParts( header: string | undefined ): { t: number; v1: string } | null { if (!header) return null; const m = header.match(/t=(\d+)[;,] ?v1=([0-9a-f]{64})/); if (!m) return null; return { t: Number(m[1]), v1: m[2] }; }