1
0
Files
bigcapital/packages/server/src/modules/BankingPlaid/BankingPlaidWebhooks.controller.ts
T
Ahmed Bouhuolia 78fb158b98 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>
2026-05-16 12:19:30 +02:00

51 lines
1.7 KiB
TypeScript

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' })
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),
);
}
}