From 8b9817d25adfa3a314a78f0b236096d5b9d5641a Mon Sep 17 00:00:00 2001 From: Sushant Date: Tue, 4 Aug 2026 23:00:01 -0700 Subject: [PATCH 1/4] fix(gitlab): fail closed when the webhook secret is missing verifyGitlabWebhookSignature returned { valid: true } whenever no secret was configured, short-circuiting before it ever read the X-Gitlab-Token header. Any unsigned request was therefore accepted outright. Return an error instead, matching the fail-closed shape already adopted by the Spotify, Zoom and Slack verifiers (#519, #520, #514). Fixes #594 --- packages/gitlab/webhooks/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitlab/webhooks/types.ts b/packages/gitlab/webhooks/types.ts index 2b0ea5935..236253fb4 100644 --- a/packages/gitlab/webhooks/types.ts +++ b/packages/gitlab/webhooks/types.ts @@ -34,7 +34,7 @@ export function verifyGitlabWebhookSignature( secret: string, ): { valid: boolean; error?: string } { if (!secret) { - return { valid: true }; + return { valid: false, error: 'Missing webhook secret' }; } const token = request.headers['x-gitlab-token']; From 7a89dc8ab3cecb538ab394a524666da2b2cbf162 Mon Sep 17 00:00:00 2001 From: Sushant Date: Tue, 4 Aug 2026 23:00:07 -0700 Subject: [PATCH 2/4] test(gitlab): cover webhook token fail-closed paths Adds packages/gitlab/webhooks/types.test.ts: missing secret, the both-missing case, missing token header, a token that does not match the secret, and the matching-token round-trip. Fixes #594 --- packages/gitlab/webhooks/types.test.ts | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/gitlab/webhooks/types.test.ts diff --git a/packages/gitlab/webhooks/types.test.ts b/packages/gitlab/webhooks/types.test.ts new file mode 100644 index 000000000..5c9e82a26 --- /dev/null +++ b/packages/gitlab/webhooks/types.test.ts @@ -0,0 +1,64 @@ +import type { WebhookRequest } from 'corsair/core'; +import type { GitlabWebhookPayload } from './types'; +import { verifyGitlabWebhookSignature } from './types'; + +describe('verifyGitlabWebhookSignature', () => { + const secret = 'my-super-secret-token'; + const payload: GitlabWebhookPayload = { + object_kind: 'push', + event_name: 'push', + }; + + const requestWith = ( + headers: Record, + ): WebhookRequest => ({ + payload, + headers, + rawBody: JSON.stringify(payload), + }); + + it('should fail closed when secret is missing', () => { + // The regression: verification returned { valid: true } unconditionally + // when no secret was configured, without ever reading the token header. + const result = verifyGitlabWebhookSignature( + requestWith({ 'x-gitlab-token': secret }), + '', + ); + expect(result).toEqual({ + valid: false, + error: 'Missing webhook secret', + }); + }); + + it('should fail closed when both secret and token header are missing', () => { + const result = verifyGitlabWebhookSignature(requestWith({}), ''); + expect(result.valid).toBe(false); + }); + + it('should return invalid if the token header is missing', () => { + const result = verifyGitlabWebhookSignature(requestWith({}), secret); + expect(result).toEqual({ + valid: false, + error: 'Missing X-Gitlab-Token header', + }); + }); + + it('should return invalid if the token does not match the secret', () => { + const result = verifyGitlabWebhookSignature( + requestWith({ 'x-gitlab-token': 'not-the-secret' }), + secret, + ); + expect(result).toEqual({ + valid: false, + error: 'X-Gitlab-Token does not match configured secret', + }); + }); + + it('should return valid when the token matches the configured secret', () => { + const result = verifyGitlabWebhookSignature( + requestWith({ 'x-gitlab-token': secret }), + secret, + ); + expect(result).toEqual({ valid: true }); + }); +}); From caed291ace825836fb3784ccc3bd56acc12b7304 Mon Sep 17 00:00:00 2001 From: Sushant Date: Tue, 4 Aug 2026 23:15:28 -0700 Subject: [PATCH 3/4] chore: re-run PR gate against the updated description From acd64fba0feaff72e38c2c92e0d916a8a490c1e5 Mon Sep 17 00:00:00 2001 From: Sushant Date: Thu, 6 Aug 2026 00:04:24 -0700 Subject: [PATCH 4/4] chore: re-run PR gate against the updated description