Skip to content

Commit 10e18ad

Browse files
Merge pull request #438 from ever-works/fix/polar-webhook-signature-verification
fix(payment): fix Polar webhook signature verification
1 parent 361bf0e commit 10e18ad

File tree

2 files changed

+228
-72
lines changed

2 files changed

+228
-72
lines changed

app/api/polar/webhook/route.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const WEBHOOK_ID_HEADER = 'webhook-id';
5757
* required: true
5858
* schema:
5959
* type: string
60-
* description: "Polar webhook signature for verification (HMAC SHA256, format: v1,<hex_signature>)"
60+
* description: "Polar webhook signature for verification (HMAC SHA256, format: v1,<hex_signature>)"
6161
* - name: "webhook-timestamp"
6262
* in: "header"
6363
* required: true
@@ -118,18 +118,15 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
118118
return NextResponse.json({ error: 'No signature provided' }, { status: 400 });
119119
}
120120

121-
// Extract signature from format "v1,<signature>"
122-
// Polar uses format: v1,<hex_signature>
123-
const signatureParts = signatureHeader.split(',');
124-
const signature = signatureParts.length > 1 ? signatureParts[1] : signatureHeader;
125-
121+
// Pass full signature header (including "v1," prefix) to provider
122+
// validateEvent expects the complete header value with format "v1,<signature>"
126123
// Process webhook through provider
127-
// Pass raw body text, signature, timestamp, and webhook-id for signature verification
124+
// Pass raw body text, signature header (full value), timestamp, and webhook-id for signature verification
128125
const polarProvider = getOrCreatePolarProvider();
129126
const webhookResult = await polarProvider.handleWebhook(
130-
body,
131-
signature,
132-
bodyText,
127+
body,
128+
signatureHeader,
129+
bodyText,
133130
timestampHeader || undefined,
134131
webhookIdHeader || undefined
135132
);
@@ -153,18 +150,18 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
153150
} catch (error) {
154151
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
155152
const isDevelopment = coreConfig.NODE_ENV === 'development';
156-
153+
157154
logger.error('Webhook processing failed', {
158155
error: errorMessage,
159156
stack: error instanceof Error ? error.stack : undefined
160157
});
161158

162159
// Don't expose internal error details in production
163160
return NextResponse.json(
164-
{
161+
{
165162
error: 'Webhook processing failed',
166163
...(isDevelopment && { details: errorMessage })
167-
},
164+
},
168165
{ status: 400 }
169166
);
170167
}

lib/payment/lib/providers/polar-provider.ts

Lines changed: 218 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { User } from '@supabase/auth-js';
22
import React from 'react';
33
import { Polar } from '@polar-sh/sdk';
4-
import crypto from 'crypto';
4+
import { validateEvent } from '@polar-sh/sdk/webhooks';
5+
56
import {
67
PaymentProviderInterface,
78
PaymentIntent,
@@ -109,6 +110,16 @@ const polarTranslations = {
109110

110111
const defaultAppUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://demo.ever.works';
111112

113+
/**
114+
* Cache entry for webhook ID deduplication
115+
* Stores webhook IDs with TTL to prevent replay attacks
116+
*/
117+
interface WebhookIdCacheEntry {
118+
webhookId: string;
119+
processedAt: number;
120+
expiresAt: number;
121+
}
122+
112123
export class PolarProvider implements PaymentProviderInterface {
113124
private polar: Polar;
114125
private webhookSecret: string;
@@ -117,6 +128,13 @@ export class PolarProvider implements PaymentProviderInterface {
117128
private apiKey: string;
118129
private isSandbox: boolean = false;
119130
private configuredApiUrl?: string;
131+
// In-memory cache for webhook ID deduplication (replay protection)
132+
// Key: webhook-id, Value: cache entry with expiration
133+
private webhookIdCache: Map<string, WebhookIdCacheEntry> = new Map();
134+
// Default replay protection window: ±300 seconds (5 minutes)
135+
private readonly REPLAY_PROTECTION_WINDOW_SECONDS: number = 300;
136+
// TTL for webhook ID cache entries: window * 2 + 60 seconds buffer (660 seconds = 11 minutes)
137+
private readonly WEBHOOK_ID_CACHE_TTL_MS: number = 660000; // (300 * 2 + 60) * 1000
120138

121139
constructor(config: PolarConfig) {
122140
if (!config.apiKey) {
@@ -134,7 +152,10 @@ export class PolarProvider implements PaymentProviderInterface {
134152
this.organizationId = config.options?.organizationId;
135153
// Clean appUrl: remove quotes, trailing slashes, and whitespace
136154
const rawAppUrl = config.options?.appUrl || defaultAppUrl;
137-
this.appUrl = rawAppUrl.trim().replace(/^["']|["']$/g, '').replace(/\/+$/, '');
155+
this.appUrl = rawAppUrl
156+
.trim()
157+
.replace(/^["']|["']$/g, '')
158+
.replace(/\/+$/, '');
138159

139160
this.configuredApiUrl = config.options?.apiUrl;
140161

@@ -1056,16 +1077,16 @@ export class PolarProvider implements PaymentProviderInterface {
10561077
}
10571078

10581079
/**
1059-
* Verifies webhook signature using Polar's official signature format
1060-
* Polar uses: HMAC SHA256 of raw request body only (hex digest)
1061-
* The webhook secret must be base64-decoded before HMAC computation
1080+
* Verifies webhook signature using Polar SDK's validateEvent function
1081+
* Uses the official @polar-sh/sdk webhook validation utility
1082+
* Also implements replay protection via timestamp and webhook-id checks
10621083
*
1063-
* @param signature - Received signature from header (hex format)
1084+
* @param signature - Received signature header value (should include "v1," prefix, e.g., "v1,<hex_signature>")
10641085
* @param rawBody - Raw request body (required, no fallback)
10651086
* @param payload - Parsed payload (unused, kept for compatibility)
1066-
* @param timestamp - Webhook timestamp (optional, used for replay protection only)
1067-
* @param webhookId - Webhook ID (optional, not used in signature)
1068-
* @throws Error if signature verification fails
1087+
* @param timestamp - Webhook timestamp (optional, used for replay protection)
1088+
* @param webhookId - Webhook ID (optional, used for idempotency/replay protection)
1089+
* @throws Error if signature verification fails or replay protection checks fail
10691090
*/
10701091
private verifyWebhookSignature(
10711092
signature: string,
@@ -1092,77 +1113,215 @@ export class PolarProvider implements PaymentProviderInterface {
10921113
throw new Error('Missing webhook-signature header required for signature verification');
10931114
}
10941115

1095-
// Validate timestamp replay protection if provided (optional but recommended)
1096-
if (timestamp) {
1097-
const webhookTime = parseInt(timestamp, 10);
1098-
const currentTime = Math.floor(Date.now() / 1000);
1099-
const tolerance = 300; // 5 minutes in seconds
1116+
try {
1117+
// Build headers object for validateEvent
1118+
// Polar SDK validateEvent expects headers with webhook-signature (full value including "v1," prefix),
1119+
// webhook-timestamp, and webhook-id
1120+
// The signature should be in format "v1,<hex_signature>" as sent by Polar
1121+
const headers: Record<string, string> = {
1122+
'webhook-signature': signature
1123+
};
11001124

1101-
if (isNaN(webhookTime)) {
1102-
this.logger.error('Invalid webhook timestamp format', {
1103-
bodyLength: rawBody.length
1104-
});
1105-
throw new Error('Invalid webhook timestamp format');
1125+
if (timestamp) {
1126+
headers['webhook-timestamp'] = timestamp;
11061127
}
11071128

1108-
if (Math.abs(currentTime - webhookTime) > tolerance) {
1109-
this.logger.error('Webhook timestamp is outside acceptable window', {
1110-
timeDifference: Math.abs(currentTime - webhookTime),
1111-
tolerance,
1112-
bodyLength: rawBody.length
1113-
});
1114-
throw new Error('Webhook timestamp is outside acceptable window');
1129+
if (webhookId) {
1130+
headers['webhook-id'] = webhookId;
11151131
}
1116-
}
11171132

1118-
// Base64-decode the webhook secret before HMAC computation (per Polar docs)
1119-
let secretKey: Buffer;
1120-
try {
1121-
secretKey = Buffer.from(this.webhookSecret, 'base64');
1133+
// Step 1: Verify HMAC signature using Polar SDK
1134+
// validateEvent takes the raw body, headers object, and secret
1135+
// It expects the webhook-signature header to contain the full value "v1,<signature>"
1136+
validateEvent(rawBody, headers, this.webhookSecret);
1137+
1138+
this.logger.debug('Webhook signature verified successfully using @polar-sh/sdk validateEvent', {
1139+
bodyLength: rawBody.length,
1140+
hasTimestamp: !!timestamp,
1141+
signatureFormat: signature.startsWith('v1,') ? 'v1,<signature>' : 'raw'
1142+
});
1143+
1144+
// Step 2: Replay protection - Timestamp validation
1145+
// Reject webhooks outside the acceptable time window (±300 seconds by default)
1146+
this.validateWebhookTimestamp(timestamp, rawBody.length, webhookId);
1147+
1148+
// Step 3: Replay protection - Idempotency check via webhook-id
1149+
// Ensure we haven't processed this webhook-id before
1150+
this.checkWebhookIdempotency(webhookId, rawBody.length, timestamp);
11221151
} catch (error) {
1123-
this.logger.error('Failed to base64-decode webhook secret', {
1124-
error: error instanceof Error ? error.message : String(error)
1152+
// Re-throw errors from replay protection checks as-is
1153+
if (
1154+
error instanceof Error &&
1155+
(error.message.includes('timestamp') || error.message.includes('webhook-id'))
1156+
) {
1157+
throw error;
1158+
}
1159+
1160+
this.logger.error('Webhook signature verification failed', {
1161+
error: error instanceof Error ? error.message : String(error),
1162+
bodyLength: rawBody.length,
1163+
hasTimestamp: !!timestamp,
1164+
hasWebhookId: !!webhookId,
1165+
signatureFormat: signature.startsWith('v1,') ? 'v1,<signature>' : 'raw'
11251166
});
1126-
throw new Error('Invalid webhook secret format (must be base64-encoded)');
1167+
throw new Error(
1168+
`Invalid webhook signature: ${error instanceof Error ? error.message : 'Verification failed'}`
1169+
);
11271170
}
1171+
}
11281172

1129-
// Compute HMAC-SHA256 over raw body only (no webhookId or timestamp)
1130-
const expectedSignature = crypto.createHmac('sha256', secretKey).update(rawBody, 'utf8').digest('hex');
1173+
/**
1174+
* Validates webhook timestamp to prevent replay attacks
1175+
* Rejects webhooks outside the acceptable time window
1176+
*
1177+
* @param timestamp - Webhook timestamp from header (Unix timestamp as string)
1178+
* @param bodyLength - Length of the request body (for logging)
1179+
* @param webhookId - Webhook ID (for logging)
1180+
* @throws Error if timestamp is missing, invalid, or outside acceptable window
1181+
*/
1182+
private validateWebhookTimestamp(
1183+
timestamp: string | undefined,
1184+
bodyLength: number,
1185+
webhookId: string | undefined
1186+
): void {
1187+
// Timestamp is required for replay protection
1188+
if (!timestamp || typeof timestamp !== 'string' || timestamp.trim().length === 0) {
1189+
this.logger.error('Webhook timestamp missing or invalid', {
1190+
bodyLength,
1191+
webhookId: webhookId || 'unknown',
1192+
hasTimestamp: !!timestamp
1193+
});
1194+
throw new Error('Webhook timestamp is required for replay protection');
1195+
}
11311196

1132-
// Convert incoming signature from hex to buffer for timing-safe comparison
1133-
let signatureBuffer: Buffer;
1134-
try {
1135-
signatureBuffer = Buffer.from(signature, 'hex');
1136-
} catch (error) {
1137-
this.logger.error('Invalid signature format (expected hex)', {
1138-
error: error instanceof Error ? error.message : String(error)
1197+
// Parse timestamp (Polar sends Unix timestamp as string)
1198+
const webhookTimestamp = parseInt(timestamp, 10);
1199+
if (isNaN(webhookTimestamp) || webhookTimestamp <= 0) {
1200+
this.logger.error('Webhook timestamp is not a valid number', {
1201+
bodyLength,
1202+
webhookId: webhookId || 'unknown',
1203+
timestamp
11391204
});
1140-
throw new Error('Invalid webhook signature format (expected hexadecimal)');
1205+
throw new Error(`Invalid webhook timestamp format: ${timestamp}`);
11411206
}
11421207

1143-
const expectedSignatureBuffer = Buffer.from(expectedSignature, 'hex');
1208+
// Calculate time difference in seconds
1209+
const currentTimestamp = Math.floor(Date.now() / 1000);
1210+
const timeDifference = Math.abs(currentTimestamp - webhookTimestamp);
1211+
1212+
// Reject if timestamp is outside acceptable window
1213+
if (timeDifference > this.REPLAY_PROTECTION_WINDOW_SECONDS) {
1214+
this.logger.error('Webhook timestamp outside acceptable window', {
1215+
bodyLength,
1216+
webhookId: webhookId || 'unknown',
1217+
webhookTimestamp,
1218+
currentTimestamp,
1219+
timeDifferenceSeconds: timeDifference,
1220+
windowSeconds: this.REPLAY_PROTECTION_WINDOW_SECONDS,
1221+
isTooOld: webhookTimestamp < currentTimestamp
1222+
});
1223+
throw new Error(
1224+
`Webhook timestamp outside acceptable window: ${timeDifference} seconds difference (max: ${this.REPLAY_PROTECTION_WINDOW_SECONDS} seconds)`
1225+
);
1226+
}
11441227

1145-
// Use constant-time comparison to prevent timing attacks
1146-
// timingSafeEqual requires buffers of equal length
1147-
const signaturesMatch =
1148-
signatureBuffer.length === expectedSignatureBuffer.length &&
1149-
crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer);
1228+
this.logger.debug('Webhook timestamp validation passed', {
1229+
bodyLength,
1230+
webhookId: webhookId || 'unknown',
1231+
timeDifferenceSeconds: timeDifference
1232+
});
1233+
}
11501234

1151-
if (!signaturesMatch) {
1152-
this.logger.error('Invalid webhook signature', {
1153-
expectedLength: expectedSignature.length,
1154-
receivedLength: signature.length,
1155-
bodyLength: rawBody.length
1235+
/**
1236+
* Checks webhook idempotency to prevent duplicate processing
1237+
* Uses in-memory cache with TTL to track processed webhook IDs
1238+
*
1239+
* @param webhookId - Webhook ID from header
1240+
* @param bodyLength - Length of the request body (for logging)
1241+
* @param timestamp - Webhook timestamp (for logging)
1242+
* @throws Error if webhook-id is missing or has already been processed
1243+
*/
1244+
private checkWebhookIdempotency(
1245+
webhookId: string | undefined,
1246+
bodyLength: number,
1247+
timestamp: string | undefined
1248+
): void {
1249+
// Webhook ID is required for idempotency checks
1250+
if (!webhookId || typeof webhookId !== 'string' || webhookId.trim().length === 0) {
1251+
this.logger.error('Webhook ID missing or invalid', {
1252+
bodyLength,
1253+
timestamp: timestamp || 'unknown',
1254+
hasWebhookId: !!webhookId
11561255
});
1157-
throw new Error('Invalid webhook signature');
1256+
throw new Error('Webhook ID is required for idempotency protection');
1257+
}
1258+
1259+
// Clean up expired cache entries periodically (every 100 checks, approximately)
1260+
if (Math.random() < 0.01) {
1261+
this.cleanupExpiredWebhookIds();
1262+
}
1263+
1264+
// Check if webhook ID was already processed
1265+
const existingEntry = this.webhookIdCache.get(webhookId);
1266+
const now = Date.now();
1267+
1268+
if (existingEntry) {
1269+
// Check if entry is still valid (not expired)
1270+
if (existingEntry.expiresAt > now) {
1271+
this.logger.error('Webhook ID already processed (duplicate/replay detected)', {
1272+
bodyLength,
1273+
webhookId,
1274+
timestamp: timestamp || 'unknown',
1275+
previouslyProcessedAt: new Date(existingEntry.processedAt).toISOString(),
1276+
timeSinceFirstProcessing: Math.floor((now - existingEntry.processedAt) / 1000) + ' seconds'
1277+
});
1278+
throw new Error(`Webhook ID already processed: ${webhookId} (replay attack detected)`);
1279+
} else {
1280+
// Entry expired, remove it
1281+
this.webhookIdCache.delete(webhookId);
1282+
}
11581283
}
11591284

1160-
this.logger.debug('Webhook signature verified successfully', {
1161-
bodyLength: rawBody.length,
1162-
hasTimestamp: !!timestamp
1285+
// Record this webhook ID with TTL
1286+
const cacheEntry: WebhookIdCacheEntry = {
1287+
webhookId,
1288+
processedAt: now,
1289+
expiresAt: now + this.WEBHOOK_ID_CACHE_TTL_MS
1290+
};
1291+
1292+
this.webhookIdCache.set(webhookId, cacheEntry);
1293+
1294+
this.logger.debug('Webhook ID recorded for idempotency', {
1295+
bodyLength,
1296+
webhookId,
1297+
timestamp: timestamp || 'unknown',
1298+
expiresAt: new Date(cacheEntry.expiresAt).toISOString()
11631299
});
11641300
}
11651301

1302+
/**
1303+
* Cleans up expired webhook ID cache entries
1304+
* Removes entries that have exceeded their TTL
1305+
*/
1306+
private cleanupExpiredWebhookIds(): void {
1307+
const now = Date.now();
1308+
let cleanedCount = 0;
1309+
1310+
for (const [webhookId, entry] of this.webhookIdCache.entries()) {
1311+
if (entry.expiresAt <= now) {
1312+
this.webhookIdCache.delete(webhookId);
1313+
cleanedCount++;
1314+
}
1315+
}
1316+
1317+
if (cleanedCount > 0) {
1318+
this.logger.debug('Cleaned up expired webhook ID cache entries', {
1319+
cleanedCount,
1320+
remainingEntries: this.webhookIdCache.size
1321+
});
1322+
}
1323+
}
1324+
11661325
/**
11671326
* Maps Polar webhook event types to internal event format
11681327
*

0 commit comments

Comments
 (0)