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:
@@ -86,6 +86,7 @@
|
|||||||
"fp-ts": "^2.16.9",
|
"fp-ts": "^2.16.9",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.0",
|
||||||
"is-my-json-valid": "^2.20.5",
|
"is-my-json-valid": "^2.20.5",
|
||||||
|
"jose": "^5.9.6",
|
||||||
"js-money": "^0.6.3",
|
"js-money": "^0.6.3",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"lamda": "^0.4.1",
|
"lamda": "^0.4.1",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { BankingPlaidWebhooksController } from './BankingPlaidWebhooks.controlle
|
|||||||
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
|
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
|
||||||
import { UpdateBankingPlaidTransitionsQueueJob } from './types/BankingPlaid.types';
|
import { UpdateBankingPlaidTransitionsQueueJob } from './types/BankingPlaid.types';
|
||||||
import { PlaidFetchTransactionsProcessor } from './jobs/PlaidFetchTransactionsJob';
|
import { PlaidFetchTransactionsProcessor } from './jobs/PlaidFetchTransactionsJob';
|
||||||
|
import { PlaidWebhookVerificationService } from './PlaidWebhookVerification.service';
|
||||||
|
|
||||||
const models = [RegisterTenancyModel(PlaidItem)];
|
const models = [RegisterTenancyModel(PlaidItem)];
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ const models = [RegisterTenancyModel(PlaidItem)];
|
|||||||
PlaidLinkTokenService,
|
PlaidLinkTokenService,
|
||||||
PlaidApplication,
|
PlaidApplication,
|
||||||
SetupPlaidItemTenantService,
|
SetupPlaidItemTenantService,
|
||||||
|
PlaidWebhookVerificationService,
|
||||||
TenancyContext,
|
TenancyContext,
|
||||||
PlaidFetchTransactionsProcessor,
|
PlaidFetchTransactionsProcessor,
|
||||||
PlaidUpdateTransactionsOnItemCreatedSubscriber,
|
PlaidUpdateTransactionsOnItemCreatedSubscriber,
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import {
|
||||||
import { PlaidWebhookDto } from './dtos/PlaidItem.dto';
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Logger,
|
||||||
|
Post,
|
||||||
|
RawBodyRequest,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { PlaidWebhookDto } from './dtos/PlaidItem.dto';
|
||||||
import { PlaidApplication } from './PlaidApplication';
|
import { PlaidApplication } from './PlaidApplication';
|
||||||
import { PublicRoute } from '../Auth/guards/jwt.guard';
|
import { PublicRoute } from '../Auth/guards/jwt.guard';
|
||||||
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
|
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
|
||||||
|
import { PlaidWebhookVerificationService } from './PlaidWebhookVerification.service';
|
||||||
|
|
||||||
@Controller('banking/plaid')
|
@Controller('banking/plaid')
|
||||||
@ApiTags('banking-plaid')
|
@ApiTags('banking-plaid')
|
||||||
@PublicRoute()
|
@PublicRoute()
|
||||||
export class BankingPlaidWebhooksController {
|
export class BankingPlaidWebhooksController {
|
||||||
|
private readonly logger = new Logger(BankingPlaidWebhooksController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly plaidApplication: PlaidApplication,
|
private readonly plaidApplication: PlaidApplication,
|
||||||
private readonly setupPlaidItemTenantService: SetupPlaidItemTenantService,
|
private readonly setupPlaidItemTenantService: SetupPlaidItemTenantService,
|
||||||
|
private readonly verificationService: PlaidWebhookVerificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('webhooks')
|
@Post('webhooks')
|
||||||
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Listen to Plaid webhooks' })
|
@ApiOperation({ summary: 'Listen to Plaid webhooks' })
|
||||||
webhooks(@Body() { itemId, webhookType, webhookCode }: PlaidWebhookDto) {
|
async webhooks(
|
||||||
return this.setupPlaidItemTenantService.setupPlaidTenant(
|
@Req() req: RawBodyRequest<Request>,
|
||||||
itemId,
|
@Headers('plaid-verification') verification: string,
|
||||||
() => {
|
@Body() { itemId, webhookType, webhookCode }: PlaidWebhookDto,
|
||||||
return this.plaidApplication.webhooks(
|
) {
|
||||||
itemId,
|
try {
|
||||||
webhookType,
|
await this.verificationService.verifyWebhook(req.rawBody, verification);
|
||||||
webhookCode,
|
} 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+243
-237
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user