Skip to main content

webhook-security

Fix race conditions, add idempotency guards, and implement timing-safe token comparisons for payment webhooks. Covers Xendit, Stripe, Paddle, and generic webhook patterns. Prevents double-crediting, replay attacks, and timing attacks. Use when building payment webhooks, fixing race conditions, or securing callback endpoints.

Quellinformationen

Repository
afu-it/security-for-vibecoders
Letzte Quellaktivität
28. Mai 2026 um 10:34
Erkannte Sprache von SKILL.md
Englisch
Sterne
3
Forks
1

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
webhook-security
description
Fix race conditions, add idempotency guards, and implement timing-safe token comparisons for payment webhooks. Covers Xendit, Stripe, Paddle, and generic webhook patterns. Prevents double-crediting, replay attacks, and timing attacks. Use when building payment webhooks, fixing race conditions, or securing callback endpoints.
version
1.0
# Webhook Security Secure payment webhook endpoints against the three most common vulnerabilities: race conditions (double-credit), replay attacks, and timing attacks. ## The Three Vulnerabilities ### 1. TOCTOU Race Condition (Double-Credit) **The bug:** Check if webhook was already processed (SELECT), then process it (INSERT + UPDATE). Two concurrent webhooks both pass the SELECT before either commits. BAD (vulnerable): ```typescript // Check-then-act -- RACE CONDITION const existing = await db.prepare('SELECT id FROM transactions WHERE ref = ?').bind(ref).first(); if (!existing) { await addCredits(db, userId, credits, ref); // Both concurrent requests reach here } ``` GOOD (atomic idempotency): ```typescript // INSERT OR IGNORE -- atomic, no race window const result = await db.prepare( "INSERT OR IGNORE INTO transactions (user_id, amount, reason, ref, created_at) VALUES (?, ?, ?, ?, datetime('now'))" ).bind(userId, credits, 'purchase', ref).run(); if (result.meta.changes === 0) { // Duplicate -- already processed, return success without crediting return new Response(JSON.stringify({ ok: true }), { status: 200 }); } // Only reaches here if INSERT succeeded (new transaction) await db.prepare( "UPDATE user_credits SET credits = credits + ? WHERE user_id = ?" ).bind(credits, userId).run(); ``` **Why this works:** The UNIQUE constraint on `ref` column makes the INSERT atomic. If two requests arrive simultaneously, only one INSERT succeeds (changes === 1). The other gets changes === 0 and returns early. ### 2. Timing Attack on Token Verification **The bug:** Using `===` or `!==` for token comparison leaks information about the correct token through response timing. BAD (vulnerable): ```typescript if (callbackToken !== expectedToken) { return new Response('Forbidden', { status: 403 }); } ``` GOOD (timing-safe): ```typescript function timingSafeEqual(a: string, b: string): boolean { const encoder = new TextEncoder(); const bufA = encoder.encode(a); const bufB = encoder.encode(b); if (bufA.byteLength !== bufB.byteLength) return false; let mismatch = 0; for (let i = 0; i < bufA.byteLength; i++) { mismatch |= bufA[i] ^ bufB[i]; } return mismatch === 0; } if (!expectedToken || !callbackToken || !timingSafeEqual(callbackToken, expectedToken)) { return new Response('Forbidden', { status: 403 }); } ``` **Why this works:** XOR comparison always iterates all bytes regardless of where the mismatch is. An attacker cannot determine correct bytes by measuring response time. ### 3. Replay Attack (Missing Idempotency) **The bug:** Processing the same webhook multiple times because there's no deduplication. **Fix:** Always store a unique reference (external_id, invoice_id) with a UNIQUE database constraint. The INSERT OR IGNORE pattern from fix #1 handles this automatically. ## Complete Webhook Handler Template ```typescript import { auditLog, generateRequestId } from '@/lib/audit'; export async function POST(request: Request) { const reqId = generateRequestId(); // 1. Verify token (timing-safe) const callbackToken = request.headers.get('x-callback-token'); if (!verifyToken(callbackToken, env.WEBHOOK_TOKEN)) { auditLog('webhook.rejected', { reason: 'invalid_token' }, reqId); return new Response(JSON.stringify({ error: 'forbidden' }), { status: 403 }); } // 2. Validate payload structure const body = await request.json().catch(() => null); if (!isValidPayload(body)) { auditLog('webhook.rejected', { reason: 'invalid_payload' }, reqId); return new Response(JSON.stringify({ error: 'invalid_payload' }), { status: 400 }); } // 3. Skip non-paid events if (body.status !== 'PAID') { auditLog('webhook.skipped', { reason: 'not_paid' }, reqId); return new Response(JSON.stringify({ ok: true }), { status: 200 }); } // 4. Validate credits match expected pack (defense-in-depth) const expectedCredits = PACKS[body.metadata.pack]?.credits; if (body.metadata.credits !== expectedCredits) { auditLog('webhook.rejected', { reason: 'credits_mismatch' }, reqId); return new Response(JSON.stringify({ error: 'invalid_payload' }), { status: 400 }); } // 5. Atomic idempotent credit (INSERT OR IGNORE) try { const result = await db.prepare( "INSERT OR IGNORE INTO transactions (user_id, amount, reason, ref, created_at) VALUES (?, ?, ?, ?, datetime('now'))" ).bind(userId, credits, 'purchase', externalId).run(); if (result.meta.changes === 0) { auditLog('webhook.duplicate', { externalId, userId }, reqId); return new Response(JSON.stringify({ ok: true }), { status: 200 }); } await db.prepare( "UPDATE user_credits SET credits = credits + ?, updated_at = datetime('now') WHERE user_id = ?" ).bind(credits, userId).run(); auditLog('webhook.credited', { externalId, userId, credits }, reqId); } catch (err) { auditLog('webhook.error', { externalId, error: String(err) }, reqId); return new Response(JSON.stringify({ error: 'internal_error' }), { status: 500 }); } return new Response(JSON.stringify({ ok: true }), { status: 200 }); } ``` ## Database Schema Requirements ```sql CREATE TABLE credit_transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, amount INTEGER NOT NULL, reason TEXT NOT NULL, xendit_ref TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(xendit_ref) -- THIS IS CRITICAL for idempotency ); CREATE INDEX idx_transactions_user ON credit_transactions(user_id); CREATE UNIQUE INDEX idx_transactions_ref ON credit_transactions(xendit_ref); ``` ## Provider-Specific Notes ### Xendit - Token header: `x-callback-token` - Static shared secret (not HMAC) - Retries on 5xx responses ### Stripe - Token header: `stripe-signature` - HMAC-SHA256 signature over request body - Use `stripe.webhooks.constructEvent()` for verification ### Paddle - Token header: `paddle-signature` - HMAC-SHA256 with timestamp - Use Paddle SDK verification ## Testing Webhook Security ```typescript test('replayed webhook does not double-credit', async () => { // First call should credit const res1 = await POST(makeWebhookRequest('invoice_123')); expect(res1.status).toBe(200); expect(creditsAdded).toBe(1); // Second call with same ID should NOT credit const res2 = await POST(makeWebhookRequest('invoice_123')); expect(res2.status).toBe(200); expect(creditsAdded).toBe(1); // Still 1, not 2 }); test('invalid token returns 403', async () => { const res = await POST(makeWebhookRequest('invoice_123', { token: 'wrong' })); expect(res.status).toBe(403); }); ``` ## PDPA 2024 Relevance Race conditions in payment processing can lead to: - Financial loss (double-crediting) - Data integrity violations (inconsistent records) - Inability to reconcile transactions during audit Under PDPA 2024, a Data Processor must implement "appropriate security measures." A payment system with known race conditions fails this standard.
Auf GitHub ansehen