fix: use svix library for Polar webhook signature verification

This commit is contained in:
kovakmedya
2026-05-04 18:47:28 +03:00
parent 89830aa28f
commit afbb029c67
4 changed files with 53 additions and 44 deletions
+2
View File
@@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@polar-sh/sdk": "^0.47.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -55,6 +56,7 @@
"react-resizable-panels": "^3.0.4",
"recharts": "3.6.0",
"sonner": "^2.0.7",
"svix": "^1.92.2",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.2",
+39
View File
@@ -23,6 +23,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.69.0(react@19.2.3))
'@polar-sh/sdk':
specifier: ^0.47.1
version: 0.47.1
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -143,6 +146,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
svix:
specifier: ^1.92.2
version: 1.92.2
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -617,6 +623,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@polar-sh/sdk@0.47.1':
resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1209,6 +1218,9 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1999,6 +2011,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -2801,6 +2816,9 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -2857,6 +2875,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svix@1.92.2:
resolution: {integrity: sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -3437,6 +3458,11 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@polar-sh/sdk@0.47.1':
dependencies:
standardwebhooks: 1.0.0
zod: 4.3.2
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -4052,6 +4078,8 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -4970,6 +4998,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-sha256@1.3.0: {}
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -5792,6 +5822,11 @@ snapshots:
stable-hash@0.0.5: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -5864,6 +5899,10 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svix@1.92.2:
dependencies:
standardwebhooks: 1.0.0
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
+5 -11
View File
@@ -9,14 +9,6 @@ import { verifyPolarWebhook } from "@/lib/payments/polar";
const PRO_VALIDITY_DAYS = 30;
export async function POST(req: NextRequest): Promise<NextResponse> {
// Polar, Svix altyapısı kullandığından hem webhook-* hem svix-* header'ları destekle
const webhookId =
req.headers.get("webhook-id") ?? req.headers.get("svix-id") ?? "";
const webhookTimestamp =
req.headers.get("webhook-timestamp") ?? req.headers.get("svix-timestamp") ?? "";
const webhookSignature =
req.headers.get("webhook-signature") ?? req.headers.get("svix-signature") ?? "";
let rawBody: string;
try {
rawBody = await req.text();
@@ -24,7 +16,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: "invalid body" }, { status: 400 });
}
if (!verifyPolarWebhook(webhookId, webhookTimestamp, webhookSignature, rawBody)) {
// Svix tüm headerları obje olarak alır
const headers: Record<string, string> = {};
req.headers.forEach((value, key) => { headers[key] = value; });
if (!verifyPolarWebhook(headers, rawBody)) {
console.error("[polar/callback] signature mismatch");
return NextResponse.json({ error: "signature mismatch" }, { status: 403 });
}
@@ -46,7 +42,6 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return new NextResponse("OK", { status: 200 });
}
// metadata'dan crm_order_id al
const metadata = (event.data as { metadata?: Record<string, string> }).metadata ?? {};
const crmOrderId = metadata.crm_order_id ?? "";
@@ -73,7 +68,6 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return new NextResponse("OK", { status: 200 });
}
// Idempotency
if (payment.status === "success" || payment.status === "failed") {
return new NextResponse("OK", { status: 200 });
}
+7 -33
View File
@@ -1,6 +1,6 @@
import "server-only";
import { createHmac, timingSafeEqual } from "crypto";
import { Webhook } from "svix";
const POLAR_API_BASE = "https://api.polar.sh";
const ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN ?? "";
@@ -52,43 +52,17 @@ export async function createPolarCheckout(params: {
return res.json() as Promise<PolarCheckout>;
}
// Standard Webhooks imza doğrulama
// Header: webhook-id, webhook-timestamp, webhook-signature
// Signed content: "{webhook-id}.{webhook-timestamp}.{rawBody}"
// Signature: "v1," + base64(HMAC-SHA256(secret, signedContent))
// Svix kullanarak Polar webhook imzasını doğrula
export function verifyPolarWebhook(
webhookId: string,
webhookTimestamp: string,
webhookSignature: string,
headers: Record<string, string>,
rawBody: string,
): boolean {
if (!WEBHOOK_SECRET) return false;
// Timestamp replay koruması (1 saat — Polar retry aralığı uzun olabilir)
const ts = parseInt(webhookTimestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 3600) return false;
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
// Polar secret: "polar_whs_<base64>" — prefix soyulup base64 decode edilir
let secretBytes: Buffer;
try {
const raw = WEBHOOK_SECRET.replace(/^(whsec_|polar_whs_)/, "");
secretBytes = Buffer.from(raw, "base64");
const wh = new Webhook(WEBHOOK_SECRET);
wh.verify(rawBody, headers);
return true;
} catch {
secretBytes = Buffer.from(WEBHOOK_SECRET);
return false;
}
const expected = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
const expectedFull = `v1,${expected}`;
// Header birden fazla imza içerebilir (space ile ayrılmış)
const signatures = webhookSignature.split(" ");
return signatures.some((sig) => {
try {
return timingSafeEqual(Buffer.from(expectedFull), Buffer.from(sig));
} catch {
return false;
}
});
}