Skip to content

Commit 57bf80a

Browse files
security: enforce webhook authentication for GitLab and Forgejo providers
GitLab: - Validate X-Gitlab-Token against configured webhook secret Forgejo/Gitea: - Validate X-Gitea-Signature using HMAC-SHA256 This prevents unauthenticated webhook requests from creating, modifying, or triggering issue and pull request workflows. Signed-off-by: sahu-virendra-1908 <virendrashivsahu@gmail.com> git log -1#:wq
1 parent e2398a7 commit 57bf80a

1 file changed

Lines changed: 129 additions & 13 deletions

File tree

server/src/api/forge-webhooks.ts

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,43 @@ export function forgeWebhookRoutes(db: Db) {
316316
* POST /api/forge/webhook/gitlab
317317
* Handle incoming GitLab webhook payloads.
318318
*/
319+
async function validateGitLabToken(
320+
db: Db,
321+
projectId: string,
322+
tokenHeader: string | undefined,
323+
): Promise<boolean> {
324+
if (!tokenHeader) return false;
325+
326+
const rows = await db
327+
.select({
328+
webhookSecret: forgeWebhooks.webhookSecret,
329+
})
330+
.from(forgeWebhooks)
331+
.where(
332+
and(
333+
eq(forgeWebhooks.projectId, projectId),
334+
eq(forgeWebhooks.active, true),
335+
),
336+
);
337+
338+
if (rows.length === 0) return false;
339+
340+
for (const row of rows) {
341+
if (!row.webhookSecret) continue;
342+
343+
const expected = Buffer.from(row.webhookSecret);
344+
const actual = Buffer.from(tokenHeader);
345+
346+
if (
347+
expected.length === actual.length &&
348+
crypto.timingSafeEqual(expected, actual)
349+
) {
350+
return true;
351+
}
352+
}
353+
354+
return false;
355+
}
319356
router.post("/forge/webhook/gitlab", async (req, res) => {
320357
try {
321358
const gitlabEvent = req.headers["x-gitlab-event"] as string;
@@ -326,7 +363,12 @@ export function forgeWebhookRoutes(db: Db) {
326363
res.status(200).json({ ignored: true, reason: "Unhandled event type" });
327364
return;
328365
}
329-
366+
const tokenHeader = req.headers["x-gitlab-token"] as string | undefined;
367+
const valid = await validateGitLabToken(db, event.projectId, tokenHeader);
368+
if (!valid) {
369+
res.status(401).json({ error: "Invalid webhook token" });
370+
return;
371+
}
330372
await storeWebhookDelivery(db, event.projectId, "gitlab", req.body as Record<string, unknown>, "received");
331373
const result = await forgeSync.processEvent(event);
332374
await storeWebhookDelivery(db, event.projectId, "gitlab", req.body as Record<string, unknown>, "processed");
@@ -355,6 +397,47 @@ export function forgeWebhookRoutes(db: Db) {
355397
* Handle incoming Forgejo webhook payloads.
356398
* Forgejo uses Gitea-compatible webhook format.
357399
*/
400+
async function validateForgejoSignature(
401+
db: Db,
402+
projectId: string,
403+
rawBody: Buffer,
404+
signatureHeader: string | undefined,
405+
): Promise<boolean> {
406+
if (!signatureHeader) return false;
407+
408+
const rows = await db
409+
.select({ webhookSecret: forgeWebhooks.webhookSecret })
410+
.from(forgeWebhooks)
411+
.where(
412+
and(
413+
eq(forgeWebhooks.projectId, projectId),
414+
eq(forgeWebhooks.active, true),
415+
),
416+
);
417+
418+
if (rows.length === 0 || !rows[0].webhookSecret) {
419+
return false;
420+
}
421+
422+
const secret = rows[0].webhookSecret;
423+
424+
const hexSig = signatureHeader.startsWith("sha256=")
425+
? signatureHeader.substring(7)
426+
: signatureHeader;
427+
428+
const expected = Buffer.from(hexSig, "hex");
429+
430+
const actual = crypto
431+
.createHmac("sha256", secret)
432+
.update(rawBody)
433+
.digest();
434+
435+
if (expected.length !== actual.length) {
436+
return false;
437+
}
438+
439+
return crypto.timingSafeEqual(expected, actual);
440+
}
358441
router.post("/forge/webhook/forgejo", async (req, res) => {
359442
try {
360443
const forgejoEvent = req.headers["x-forgejo-event"] ?? req.headers["x-gitea-event"];
@@ -366,6 +449,39 @@ export function forgeWebhookRoutes(db: Db) {
366449
return;
367450
}
368451

452+
const rawBody = (
453+
req as Request & {
454+
rawBody?: Buffer;
455+
}
456+
).rawBody;
457+
458+
const signatureHeader = req.headers["x-gitea-signature"];
459+
460+
const signatureValue = Array.isArray(signatureHeader)
461+
? signatureHeader[0]
462+
: signatureHeader;
463+
464+
if (!rawBody) {
465+
res.status(400).json({
466+
error: "Webhook raw body unavailable",
467+
});
468+
return;
469+
}
470+
471+
const valid = await validateForgejoSignature(
472+
db,
473+
event.projectId,
474+
rawBody,
475+
signatureValue,
476+
);
477+
478+
if (!valid) {
479+
res.status(401).json({
480+
error: "Invalid webhook signature",
481+
});
482+
return;
483+
}
484+
369485
await storeWebhookDelivery(db, event.projectId, "forgejo", req.body as Record<string, unknown>, "received");
370486
const result = await forgeSync.processEvent(event);
371487
await storeWebhookDelivery(db, event.projectId, "forgejo", req.body as Record<string, unknown>, "processed");
@@ -529,8 +645,8 @@ function mapGitHubEvent(eventName: string, payload: Record<string, unknown>): Fo
529645
const issue = (payload as Record<string, unknown>).issue as Record<string, unknown>;
530646
const eventType = action === "opened" ? "issue_opened"
531647
: action === "closed" ? "issue_closed"
532-
: action === "reopened" ? "issue_reopened"
533-
: null;
648+
: action === "reopened" ? "issue_reopened"
649+
: null;
534650
if (!eventType || !issue) return null;
535651
return {
536652
...base,
@@ -560,8 +676,8 @@ function mapGitHubEvent(eventName: string, payload: Record<string, unknown>): Fo
560676
const pr = (payload as Record<string, unknown>).pull_request as Record<string, unknown>;
561677
const eventType = action === "opened" ? "pr_opened"
562678
: action === "closed" && (pr as Record<string, unknown>)?.merged ? "pr_merged"
563-
: action === "closed" ? "pr_closed"
564-
: null;
679+
: action === "closed" ? "pr_closed"
680+
: null;
565681
if (!eventType || !pr) return null;
566682
return {
567683
...base,
@@ -606,8 +722,8 @@ function mapGitLabEvent(eventName: string, payload: Record<string, unknown>): Fo
606722
const action = attrs.action as string;
607723
const eventType = action === "open" ? "issue_opened"
608724
: action === "close" ? "issue_closed"
609-
: action === "reopen" ? "issue_reopened"
610-
: null;
725+
: action === "reopen" ? "issue_reopened"
726+
: null;
611727
if (!eventType) return null;
612728
return {
613729
...base,
@@ -636,8 +752,8 @@ function mapGitLabEvent(eventName: string, payload: Record<string, unknown>): Fo
636752
const action = attrs.action as string;
637753
const eventType = action === "open" ? "pr_opened"
638754
: action === "close" ? "pr_closed"
639-
: action === "merge" ? "pr_merged"
640-
: null;
755+
: action === "merge" ? "pr_merged"
756+
: null;
641757
if (!eventType) return null;
642758
return {
643759
...base,
@@ -680,8 +796,8 @@ function mapForgejoEvent(eventName: string, payload: Record<string, unknown>): F
680796
const issue = (payload as Record<string, unknown>).issue as Record<string, unknown>;
681797
const eventType = action === "opened" ? "issue_opened"
682798
: action === "closed" ? "issue_closed"
683-
: action === "reopened" ? "issue_reopened"
684-
: null;
799+
: action === "reopened" ? "issue_reopened"
800+
: null;
685801
if (!eventType || !issue) return null;
686802
return {
687803
...base,
@@ -711,8 +827,8 @@ function mapForgejoEvent(eventName: string, payload: Record<string, unknown>): F
711827
const pr = (payload as Record<string, unknown>).pull_request as Record<string, unknown>;
712828
const eventType = action === "opened" ? "pr_opened"
713829
: action === "closed" && (pr as Record<string, unknown>)?.merged ? "pr_merged"
714-
: action === "closed" ? "pr_closed"
715-
: null;
830+
: action === "closed" ? "pr_closed"
831+
: null;
716832
if (!eventType || !pr) return null;
717833
return {
718834
...base,

0 commit comments

Comments
 (0)