+1

Khi App Bị Đâm Lén: Giải Mã Webhooks & Bài Toán Đảm Bảo Tính Toàn Vẹn Dữ Liệu

Ở những bài trước, chúng ta luôn đóng vai là "Kẻ chủ động": Trình duyệt gọi lên Backend, Backend gọi sang Elasticsearch, Backend mang thư sang nhờ Google xác thực... Đó là mô hình Pull (Kéo).

Nhưng khi bạn bước chân vào thế giới của Thương mại điện tử (E-commerce), Fintech, hay tích hợp API thanh toán (Momo, VNPay, Stripe), cuộc chơi sẽ đảo ngược. Bạn không thể cứ mỗi 5 giây lại gọi sang VNPay hỏi: "Anh ơi, thằng Hiếu nó chuyển tiền chưa?" (Kỹ thuật này gọi là Polling - tốn băng thông và cực kỳ nghiệp dư).

Thay vào đó, VNPay sẽ bảo bạn: "Cứ ngủ đi, khi nào nó chuyển tiền thành công, tao sẽ chủ động gọi điện báo cho mày!". Cuộc gọi ngược đó chính là Webhook.

Nhưng làm sao bạn biết cuộc gọi đó thực sự đến từ VNPay, hay là một thằng Hacker đang nhại giọng? Dưới đây là bài viết bóc trần thế giới Webhook và nghệ thuật bảo vệ hệ thống khỏi những nhát dao đâm lén!

PHẦN 1: TỬ HUYỆT CỦA SỰ NGÂY THƠ

Khi tích hợp thanh toán, bạn sẽ cung cấp cho VNPay hoặc Stripe một cái URL tĩnh. Ví dụ: POST https://api.vibecoder.com/webhooks/payment

Tư duy Thợ gõ (Spaghetti Code):

Họ viết API tiếp nhận Webhook cực kỳ ngây thơ như sau:

app.post('/webhooks/payment', async (req, res) => {
    const data = req.body;
    
    // NGÂY THƠ: Tin tưởng tuyệt đối vào dữ liệu được gửi đến
    if (data.status === 'SUCCESS') {
        await db.query(`UPDATE orders SET status = 'PAID' WHERE id = ?`, [data.order_id]);
        return res.status(200).send('OK');
    }
});

Thảm họa ập đến:

Cái URL /webhooks/payment của bạn là Public (Mở công khai trên mạng). Bất kỳ ai cũng có thể gọi vào đó. Một thằng Hacker lên website của bạn, đặt mua cái Macbook 50 triệu. Nó lấy được cái order_id = 9999. Thay vì móc ví ra trả tiền, nó mở Postman lên, nhắm thẳng vào API Webhook của bạn và bắn một cục JSON tự chế: {"order_id": 9999, "status": "SUCCESS", "amount": 50000000}

API của bạn đọc chữ SUCCESS, vui vẻ update Database, thông báo kho giao hàng. Bạn vừa mất cái Macbook 50 triệu vì quá ngây thơ!

PHẦN 2: TẤM KHIÊN CHỮ KÝ ĐIỆN TỬ (HMAC SIGNATURE)

Làm sao để Server của bạn nhận ra thằng Hacker? Trong thế giới Webhook, không có chuyện đăng nhập hay Session. Mọi sự tin tưởng được xây dựng dựa trên Mật Mã Ký Quỹ (HMAC - Hash-based Message Authentication Code).

Quy trình thỏa hiệp bí mật:

  1. Khi bạn đăng ký tài khoản dev trên Stripe/VNPay, họ cấp cho bạn một cái Secret Key (Khóa bí mật). (VD: vibe_secret_123). Khóa này CHỈ CÓ bạn và Stripe biết. Hacker không biết.
  2. Khi Stripe muốn gửi Webhook báo có người chuyển 50 triệu, nó không chỉ gửi cục JSON.
  3. Stripe đem cục JSON đó + vibe_secret_123 nhét vào một cái máy xay sinh tố toán học (Thuật toán SHA-256) -> Xay ra một chuỗi ký tự loằng ngoằng. Gọi là Chữ ký (Signature).
  4. Stripe gửi cho bạn cục JSON (nằm ở Body) kèm theo cái Chữ ký kia (thường nằm ở Header: Stripe-Signature hoặc X-VNPay-Signature).

Nhiệm vụ của Vibe Coder tại Backend:

Khi nhận được Webhook, TUYỆT ĐỐI CHƯA LƯU DATABASE. Bạn phải làm phép thử nghiệm:

  1. Bạn lấy cục JSON vừa nhận được.
  2. Bạn lấy cái Khóa bí mật đang giấu trong file .env của bạn.
  3. Bạn cũng cho vào máy xay sinh tố SHA-256 hệt như cách Stripe làm.
  4. Bạn đem kết quả so sánh với cái Chữ ký nằm trên Header mà Stripe gửi tới.
  • Nếu trùng khớp: 100% dữ liệu này do Stripe gửi, và KHÔNG BỊ CHỈNH SỬA trên đường truyền.
  • Nếu Hacker can thiệp: Hacker tự gửi JSON "status": "SUCCESS". Nhưng vì nó không có Khóa bí mật, máy xay của nó sẽ cho ra Chữ ký sai bét. Hoặc nếu nó bắt trộm được Webhook thật, nhưng cố tình sửa giá tiền từ 100.000 thành 50.000.000, thì nội dung JSON thay đổi -> Chữ ký do bạn xay ra sẽ khác biệt hoàn toàn với Chữ ký gốc.

PHẦN 3: CODE THỰC CHIẾN - CÚ LỪA KINH ĐIỂN CỦA NODE.JS

Biết nguyên lý là một chuyện, nhưng code bằng Node.js cực kỳ dễ đạp mìn. Cú lừa đau đớn nhất mang tên: req.body đã bị xào nấu!

Thuật toán Hash yêu cầu bạn phải đưa cục JSON vào chính xác đến từng byte, từng khoảng trắng. Nhưng trong Express.js, bạn thường dùng app.use(express.json()). Thằng middleware này nó đã âm thầm parse (giải mã) cục JSON dạng thô (Raw String) thành Object Javascript, làm mất đi các khoảng trắng gốc. Nếu bạn lấy Object đó ép lại thành chuỗi rồi đem đi Hash, chữ ký sẽ LUÔN LUÔN BỊ SAI!

Giải pháp Vibe Coder: Bạn phải lấy được Raw Body (Dữ liệu thô chưa qua chế biến).

const crypto = require('crypto');
const express = require('express');
const app = express();

// CẤU HÌNH ĐẶC BIỆT CHỈ CHO WEBHOOK: Lấy Raw Body
app.post('/webhooks/payment', express.raw({ type: 'application/json' }), async (req, res) => {
    
    // 1. Lấy Chữ ký do Cổng thanh toán gửi
    const signatureFromHeader = req.headers['stripe-signature'];
    
    // 2. Lấy dữ liệu THÔ (Raw Body)
    const rawBody = req.body; 
    const secretKey = process.env.WEBHOOK_SECRET;

    // 3. Cho vào máy xay sinh tố HMAC-SHA256
    const mySignature = crypto
        .createHmac('sha256', secretKey)
        .update(rawBody)
        .digest('hex');

    // 4. So sánh bảo mật (Dùng timingSafeEqual để chống Timing Attack)
    try {
        const isValid = crypto.timingSafeEqual(
            Buffer.from(mySignature),
            Buffer.from(signatureFromHeader)
        );

        if (!isValid) {
            console.error('🚨 PHÁT HIỆN GIẢ MẠO WEBHOOK!');
            return res.status(400).send('Invalid Signature');
        }

        // Tới đây thì an tâm 100% rồi, biến rawBody thành Object và lưu DB thôi!
        const data = JSON.parse(rawBody);
        await db.query(`UPDATE orders SET status = 'PAID' WHERE id = ?`, [data.order_id]);
        
        return res.status(200).send('OK');

    } catch (err) {
        return res.status(400).send('Webhook Error');
    }
});

PHẦN 4: IDEMPOTENCY - BÀI TOÁN "CHỐNG BẤM NÚT 2 LẦN"

Bảo mật xong rồi, nhưng hệ thống phân tán còn một thảm họa mạng lưới nữa.

Kịch bản: Stripe gửi Webhook cho bạn. Server của bạn xử lý thành công, tiến hành xuất hóa đơn, cộng tiền cho ví của User. Nhưng khi Server của bạn chuẩn bị trả về res.status(200).send('OK') thì... Cáp quang biển bị đứt! Stripe KHÔNG nhận được chữ OK. Theo luật của Webhooks, nếu Stripe không nhận được 200 OK sau 5 giây, nó tự hiểu là Server của bạn bị sập, nên 1 phút sau nó sẽ GỬI LẠI Webhook đó một lần nữa!

Bạn nhận lại Webhook y hệt, bạn check chữ ký vẫn đúng. Bạn lại... cộng tiền cho User lần thứ 2!

Giải pháp: Tính Không Đổi (Idempotency)

Một Vibe Coder phải thiết kế API sao cho: "Dù mày có gọi tao 1 lần hay 100 lần với cùng một cục dữ liệu, kết quả cuối cùng vẫn chỉ được tính là 1 lần".

  • Cách làm: Mọi Webhook uy tín đều có một mã event_id hoặc transaction_id duy nhất cho mỗi giao dịch.
  • Bạn tạo một bảng Database tên là processed_events (Cột event_id set là UNIQUE).
  • Khi nhận Webhook, bước đầu tiên: Lấy event_id đó đi INSERT vào bảng processed_events.
  • Nếu thành công: Mới xử lý cộng tiền.
  • Nếu Database văng lỗi Duplicate Entry (Trùng lặp): Tự hiểu là event này đã xử lý rồi, bỏ qua logic cộng tiền, và trả về luôn 200 OK cho bọn Stripe câm miệng lại.

Lời kết

API Webhook là cánh cửa hậu (Backdoor) của hệ thống. Kẻ thù không gõ cửa chính (Login), mà chúng sẽ đập cửa hậu. Việc thiết lập tấm khiên HMAC Signature để bắt ma, và Idempotency để chống dội bom là hai điều kiện tiên quyết trước khi bạn đưa bất kỳ tính năng thanh toán nào lên Production.

Chủ đề tiếp theo: Khi Giao Dịch Thất Bại - Bù Trừ (Compensation) Trong Microservices

Bạn đã nhận được Webhook báo tiền vào. Bạn tiến hành Giao Hàng. Nhưng... Kho báo hết hàng! Bạn không thể giao hàng. Lúc này tiền của khách đã trừ rồi, bạn phải làm sao?

Trong một hệ thống nguyên khối (Monolith) dùng chung 1 cái Database, bạn có thể dùng lệnh ROLLBACK của SQL là xong. Nhưng hệ thống của bạn là Microservices! Service Thanh Toán nằm ở máy chủ khác. Service Kho Hàng nằm ở máy chủ khác. SQL không thể Rollback xuyên server được!

Làm sao để đảm bảo "Sự nhất quán dữ liệu" trên diện rộng? Ở bài viết tới, chúng ta sẽ bước vào cơn ác mộng lớn nhất của kiến trúc sư hệ thống: Distributed Transactions (Giao dịch phân tán) và pattern nổi tiếng mang tên SAGA Pattern. Đội mũ bảo hiểm vào nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí