Merge pull request #1095 from bigcapitalhq/fix/plaid-webhook-signature-verification
fix(server): verify Plaid webhook signatures (GHSA-g56w-g54f-whq5)
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user