1
23import { Body, Controller, Headers, HttpCode, Inject, Param, Post, Req } from "@nestjs/common";
24import type { Request } from "express";
25import { createHash } from "node:crypto";
26import {
27 RUN_INPUT_MAX_BYTES,
28 WEBHOOK_RATE_PER_MINUTE,
29 truncateJson,
30 verifyWebhookSignature
31} from "@factory/shared";
32import { v4 as uuid } from "uuid";
33import { ApiError } from "../../common/errors.js";
34import { AuditService } from "../../common/audit.service.js";
35import { RedisService } from "../../common/redis.service.js";
36import { AutomationsService } from "./automations.service.js";
37import { webhookRateKey } from "@factory/shared";
38
39
40const REPLAY_WINDOW_SECONDS = 10 * 60;
41
42
43const SAFE_HEADERS: Record<string, string> = {
44 "content-type": "content-type",
45 "user-agent": "user-agent",
46 "x-request-id": "x-request-id"
47};
48
49@Controller("webhooks/incoming")
50export class WebhookIncomingController {
51 constructor(
52 @Inject(AutomationsService) private readonly automations: AutomationsService,
53 @Inject(RedisService) private readonly redis: RedisService,
54 @Inject(AuditService) private readonly audit: AuditService
55 ) {}
56
57 @Post(":webhookId")
58 @HttpCode(202)
59 async receive(
60 @Param("webhookId") webhookId: string,
61 @Headers() headers: Record<string, string | undefined>,
62 @Req() req: Request & { rawBody?: Buffer }
63 ): Promise<{ data: { runId: string } }> {
64
65
66
67 const raw = req.rawBody;
68 if (!Buffer.isBuffer(raw) || raw.length === 0) {
69 throw ApiError.validation("Request body must be non-empty application/json.");
70 }
71 const rawBody = raw.toString("utf8");
72
73
74 const trigger = await this.automations.findActiveWebhook(webhookId);
75 if (!trigger) throw ApiError.notFound("Webhook not found.");
76 const cfg = trigger.config as Record<string, unknown>;
77 const encrypted = cfg.secretEncrypted;
78 if (typeof encrypted !== "string" || !encrypted) throw ApiError.notFound("Webhook not found.");
79 const automation = trigger.automation;
80
81
82 const rateKey = webhookRateKey(webhookId);
83 const r = this.redis.getRedis();
84 const count = await r.incr(rateKey);
85 if (count === 1) await r.expire(rateKey, 60);
86 if (count > WEBHOOK_RATE_PER_MINUTE) {
87 throw ApiError.webhookRateLimited();
88 }
89
90
91
92 const signatureHeader =
93 headers["x-factory-signature"] ?? headers["X-Factory-Signature"] ??
94 headers["x-fs-signature"] ?? headers["X-FS-Signature"];
95 const secret = this.automations.unwrapSecret(encrypted);
96 const verdict = verifyWebhookSignature(secret, signatureHeader, rawBody, Date.now());
97 if (verdict === "WEBHOOK_TIMESTAMP_STALE") throw ApiError.webhookTimestampStale();
98 if (verdict === "WEBHOOK_SIGNATURE_INVALID") throw ApiError.webhookSignatureInvalid();
99
100
101
102 const parts = verifyWebhookSignatureParts(signatureHeader);
103 const bodyHash = createHash("sha256").update(rawBody).digest("hex");
104 const nonceKey = `webhook:replay:${webhookId}:${parts ? parts.t : "unknown"}:${bodyHash}`;
105 const firstSeen = await r.set(nonceKey, "1", "EX", REPLAY_WINDOW_SECONDS, "NX");
106 if (firstSeen !== "OK") throw ApiError.webhookReplayDetected();
107
108
109 let parsedBody: unknown;
110 try {
111 parsedBody = JSON.parse(rawBody);
112 } catch {
113 throw ApiError.validation("Webhook body must be valid JSON.");
114 }
115
116 const safeHeaders: Record<string, string> = {};
117 for (const [key, headerName] of Object.entries(SAFE_HEADERS)) {
118 const v = headers[key] ?? headers[headerName];
119 if (typeof v === "string") safeHeaders[headerName] = v;
120 }
121
122 const triggerEvent: Record<string, unknown> = {
123 type: "webhook",
124 payload: parsedBody,
125 headers: safeHeaders
126 };
127
128
129 const run = await this.automations.createIncomingRun({
130 workspaceId: automation.workspaceId,
131 automationId: automation.id,
132 triggerEvent,
133 input: truncateJson(triggerEvent, RUN_INPUT_MAX_BYTES).value
134 });
135 await this.automations.enqueueRunPublic(run.id, automation.id, automation.workspaceId, triggerEvent, uuid());
136
137
138 await this.audit.audit(req, undefined, {
139 actorType: "INTEGRATION",
140 actorId: webhookId,
141 workspaceId: automation.workspaceId,
142 resourceType: "automation",
143 resourceId: automation.id,
144 action: "webhook.received",
145 metadata: { runId: run.id }
146 });
147
148
149 this.automations.logWebhookHit(webhookId, automation.id, run.id, safeHeaders);
150
151
152
153 return { data: { runId: run.id } };
154 }
155}
156
157
160function verifyWebhookSignatureParts(
161 header: string | undefined
162): { t: number; v1: string } | null {
163 if (!header) return null;
164 const m = header.match(/t=(\d+)[;,] ?v1=([0-9a-f]{64})/);
165 if (!m) return null;
166 return { t: Number(m[1]), v1: m[2] };
167}
168