Xử lý Webhook an toàn trong Laravel

· 4 min read

Webhooks là thành phần quan trọng của các ứng dụng hiện đại. Chúng cho phép các dịch vụ bên thứ 3 (Stripe, GitHub, Mailgun) thông báo cho ứng dụng của bạn khi sự kiện xảy ra. Tuy nhiên, chúng cũng mang đến những thách thức về bảo mật và hiệu năng.

Nếu bạn xử lý webhook kém, kẻ tấn công có thể giả mạo sự kiện, hoặc một lượng lớn webhook đổ về có thể đánh sập server của bạn.

1. Xác minh Chữ ký (Verify Signature)

Không xác minh danh tính người gửi là lỗ hổng bảo mật nghiêm trọng. Bên gửi thường ký vào payload bằng một secret key. Bạn bắt buộc phải kiểm tra chữ ký này.

Ví dụ: Xác minh GitHub Webhook

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyGithubWebhook
{
    public function handle(Request $request, Closure $next): Response
    {
        $signature = $request->header('X-Hub-Signature-256');
        $payload = $request->getContent();
        $secret = config('services.github.webhook_secret');

        $computed = 'sha256=' . hash_hmac('sha256', $payload, $secret);

        if (!hash_equals($computed, $signature)) {
            abort(403, 'Invalid signature');
        }

        return $next($request);
    }
}

Lưu ý: Luôn sử dụng hash_equals để so sánh chuỗi nhằm chống lại tấn công timing (timing attacks).

2. Xử lý Bất đồng bộ (Queues)

Tuyệt đối không xử lý logic nghiệp vụ nặng ngay trong controller webhook. Webhook client mong đợi phản hồi 200 OK nhanh chóng. Nếu bạn xử lý quá lâu, bên cung cấp sẽ timeout và gửi lại (retry), gây ra trùng lặp.

Cách làm sai:

public function handle(Request $request) {
    // Chặn request 5s để gửi email
    Mail::to($user)->send(...); 
    return response('OK');
}

Cách làm đúng:

public function handle(Request $request) {
    // 1. Verify Signature (Middleware)
    
    // 2. Đẩy vào hàng đợi
    ProcessWebhookJob::dispatch($request->all());
    
    // 3. Phản hồi ngay lập tức
    return response('Received', 200);
}

3. Tính idempotency (Tránh trùng lặp)

Các nhà cung cấp đảm bảo việc gửi tin "ít nhất một lần" (at least once). Điều này có nghĩa là bạn có thể nhận được cùng một sự kiện webhook 2 lần.

Logic xử lý của bạn phải đảm bảo tính idempotent.

Nếu sự kiện là invoice.paid, kiểm tra xem bạn đã xử lý ID hóa đơn này chưa.

// Trong ProcessWebhookJob

public function handle()
{
    $eventId = $this->payload['id'];
    
    if (WebhookReceipt::where('event_id', $eventId)->exists()) {
        return; // Đã xử lý rồi
    }

    // Logic xử lý...
    
    WebhookReceipt::create(['event_id' => $eventId]);
}

4. Vấn đề thứ tự (Ordering)

Webhook không phải lúc nào cũng đến đúng thứ tự. Hãy sử dụng timestamp trong nội dung gói tin, đừng dùng thời gian ứng dụng bạn nhận được tin.

Nếu bạn nhận subscription.updated (timestamp: 10:05) trước subscription.created (timestamp: 10:00) do nghẽn mạng, code của bạn phải xử lý được tình huống này (ví dụ tạo luôn user nếu chưa có).

5. Security - CSRF Exception

Nhớ loại bỏ route webhook khỏi lớp bảo vệ CSRF trong bootstrap/app.php (hoặc VerifyCsrfToken middleware), vì các dịch vụ bên ngoài không thể cung cấp CSRF token cho bạn.

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'webhooks/*',
    ]);
})

Tổng kết

Để ngủ ngon hàng đêm:

  1. Verify Signatures: Đảm bảo request đến từ chính chủ.
  2. Queue mọi thứ: Trả lời nhanh, làm việc sau.
  3. Xử lý trùng lặp: Kiểm tra Event ID.
  4. Giám sát lỗi: Sử dụng Laravel Horizon để phát hiện các webhook job bị lỗi.

Bình luận