From afbb029c67d758487bcd4e09776743a7015f918c Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Mon, 4 May 2026 18:47:28 +0300 Subject: [PATCH] fix: use svix library for Polar webhook signature verification --- package.json | 2 + pnpm-lock.yaml | 39 +++++++++++++++++++ src/app/api/payments/polar/callback/route.ts | 16 +++----- src/lib/payments/polar.ts | 40 ++++---------------- 4 files changed, 53 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 8a58292..8bf638b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0722f22..a810445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/api/payments/polar/callback/route.ts b/src/app/api/payments/polar/callback/route.ts index 1a38e67..d14a81c 100644 --- a/src/app/api/payments/polar/callback/route.ts +++ b/src/app/api/payments/polar/callback/route.ts @@ -9,14 +9,6 @@ import { verifyPolarWebhook } from "@/lib/payments/polar"; const PRO_VALIDITY_DAYS = 30; export async function POST(req: NextRequest): Promise { - // 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 { 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 = {}; + 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 { return new NextResponse("OK", { status: 200 }); } - // metadata'dan crm_order_id al const metadata = (event.data as { metadata?: Record }).metadata ?? {}; const crmOrderId = metadata.crm_order_id ?? ""; @@ -73,7 +68,6 @@ export async function POST(req: NextRequest): Promise { return new NextResponse("OK", { status: 200 }); } - // Idempotency if (payment.status === "success" || payment.status === "failed") { return new NextResponse("OK", { status: 200 }); } diff --git a/src/lib/payments/polar.ts b/src/lib/payments/polar.ts index 4408888..e99c250 100644 --- a/src/lib/payments/polar.ts +++ b/src/lib/payments/polar.ts @@ -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; } -// 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, 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_" — 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; - } - }); }