1
0

fix(server): verify Plaid webhook signatures (GHSA-g56w-g54f-whq5)

POST /api/banking/plaid/webhooks was @PublicRoute() and processed the
body without verifying Plaid's Plaid-Verification JWT, letting any
unauthenticated client replay or fabricate webhook events for a tenant
by guessing a plaidItemId.

Add PlaidWebhookVerificationService that verifies the Plaid-Verification
ES256 JWS using a JWK fetched from plaidClient.webhookVerificationKeyGet
(cached per kid via lru-cache for 24h), enforces a 5-minute iat replay
window through jose.jwtVerify({ maxTokenAge }), and timing-safe compares
the body's SHA-256 against the request_body_sha256 claim. The webhook
controller now consumes the raw body and the plaid-verification header,
runs verification before setupPlaidTenant, and returns 400 Bad Request
on any failure - so no tenant context is ever set for an unsigned or
tampered request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmed Bouhuolia
2026-05-16 12:19:30 +02:00
parent cd8d10afc0
commit 78fb158b98
5 changed files with 378 additions and 249 deletions
+1
View File
@@ -86,6 +86,7 @@
"fp-ts": "^2.16.9",
"ioredis": "^5.6.0",
"is-my-json-valid": "^2.20.5",
"jose": "^5.9.6",
"js-money": "^0.6.3",
"knex": "^3.1.0",
"lamda": "^0.4.1",
@@ -24,6 +24,7 @@ import { BankingPlaidWebhooksController } from './BankingPlaidWebhooks.controlle
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
import { UpdateBankingPlaidTransitionsQueueJob } from './types/BankingPlaid.types';
import { PlaidFetchTransactionsProcessor } from './jobs/PlaidFetchTransactionsJob';
import { PlaidWebhookVerificationService } from './PlaidWebhookVerification.service';
const models = [RegisterTenancyModel(PlaidItem)];
@@ -50,6 +51,7 @@ const models = [RegisterTenancyModel(PlaidItem)];
PlaidLinkTokenService,
PlaidApplication,
SetupPlaidItemTenantService,
PlaidWebhookVerificationService,
TenancyContext,
PlaidFetchTransactionsProcessor,
PlaidUpdateTransactionsOnItemCreatedSubscriber,
@@ -1,31 +1,50 @@
import { Body, Controller, Post } from '@nestjs/common';
import { PlaidWebhookDto } from './dtos/PlaidItem.dto';
import {
BadRequestException,
Body,
Controller,
Headers,
HttpCode,
Logger,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PlaidWebhookDto } from './dtos/PlaidItem.dto';
import { PlaidApplication } from './PlaidApplication';
import { PublicRoute } from '../Auth/guards/jwt.guard';
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
import { PlaidWebhookVerificationService } from './PlaidWebhookVerification.service';
@Controller('banking/plaid')
@ApiTags('banking-plaid')
@PublicRoute()
export class BankingPlaidWebhooksController {
private readonly logger = new Logger(BankingPlaidWebhooksController.name);
constructor(
private readonly plaidApplication: PlaidApplication,
private readonly setupPlaidItemTenantService: SetupPlaidItemTenantService,
private readonly verificationService: PlaidWebhookVerificationService,
) {}
@Post('webhooks')
@HttpCode(200)
@ApiOperation({ summary: 'Listen to Plaid webhooks' })
webhooks(@Body() { itemId, webhookType, webhookCode }: PlaidWebhookDto) {
return this.setupPlaidItemTenantService.setupPlaidTenant(
itemId,
() => {
return this.plaidApplication.webhooks(
itemId,
webhookType,
webhookCode,
);
},
async webhooks(
@Req() req: RawBodyRequest<Request>,
@Headers('plaid-verification') verification: string,
@Body() { itemId, webhookType, webhookCode }: PlaidWebhookDto,
) {
try {
await this.verificationService.verifyWebhook(req.rawBody, verification);
} catch (err) {
this.logger.warn(`Plaid webhook verification failed: ${err.message}`);
throw new BadRequestException('Invalid Plaid webhook signature');
}
return this.setupPlaidItemTenantService.setupPlaidTenant(itemId, () =>
this.plaidApplication.webhooks(itemId, webhookType, webhookCode),
);
}
}
@@ -0,0 +1,101 @@
import { createHash, timingSafeEqual } from 'crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
decodeProtectedHeader,
importJWK,
jwtVerify,
type JWK,
type KeyLike,
} from 'jose';
import * as LRUCache from 'lru-cache';
import type { PlaidApi } from 'plaid';
import { PLAID_CLIENT } from '../Plaid/Plaid.module';
const ALLOWED_ALG = 'ES256';
const MAX_TOKEN_AGE = '5m';
const KEY_CACHE_MAX = 100;
const KEY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
type ImportedKey = KeyLike | Uint8Array;
@Injectable()
export class PlaidWebhookVerificationService {
private readonly keyCache: LRUCache<string, ImportedKey> = new LRUCache({
max: KEY_CACHE_MAX,
maxAge: KEY_CACHE_TTL_MS,
});
constructor(@Inject(PLAID_CLIENT) private readonly plaidClient: PlaidApi) {}
public async verifyWebhook(
rawBody: Buffer | undefined,
verificationHeader: string | undefined,
): Promise<void> {
if (!rawBody || rawBody.length === 0) {
throw new Error('Plaid webhook raw body missing');
}
if (!verificationHeader) {
throw new Error('Plaid-Verification header missing');
}
const header = decodeProtectedHeader(verificationHeader);
if (header.alg !== ALLOWED_ALG) {
throw new Error(`Unexpected webhook JWT alg: ${header.alg}`);
}
if (!header.kid) {
throw new Error('Webhook JWT missing kid header');
}
const key = await this.getVerificationKey(header.kid);
const { payload } = await jwtVerify(verificationHeader, key, {
algorithms: [ALLOWED_ALG],
maxTokenAge: MAX_TOKEN_AGE,
});
const expectedHash = payload['request_body_sha256'];
if (typeof expectedHash !== 'string') {
throw new Error('Webhook JWT missing request_body_sha256 claim');
}
const actualHash = createHash('sha256').update(rawBody).digest('hex');
if (!this.constantTimeEquals(actualHash, expectedHash)) {
throw new Error('Webhook body hash mismatch');
}
}
private async getVerificationKey(kid: string): Promise<ImportedKey> {
const cached = this.keyCache.get(kid);
if (cached) return cached;
const response = await this.plaidClient.webhookVerificationKeyGet({
key_id: kid,
});
const plaidKey = response.data.key;
if (
plaidKey.expired_at !== null &&
plaidKey.expired_at !== undefined &&
plaidKey.expired_at * 1000 < Date.now()
) {
throw new Error(`Plaid verification key ${kid} is expired`);
}
const jwk: JWK = {
kty: plaidKey.kty,
crv: plaidKey.crv,
x: plaidKey.x,
y: plaidKey.y,
alg: plaidKey.alg,
use: plaidKey.use,
kid: plaidKey.kid,
};
const imported = await importJWK(jwk, ALLOWED_ALG);
this.keyCache.set(kid, imported);
return imported;
}
private constantTimeEquals(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
}
}