78fb158b98
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>
51 lines
1.7 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
}
|