Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
VerifySendgridSignature
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3namespace App\Http\Middleware;
4
5use Closure;
6use Illuminate\Http\Request;
7use Illuminate\Support\Facades\Log;
8use Symfony\Component\HttpFoundation\Response;
9
10/**
11 * SendGrid Event Webhook signature verification.
12 *
13 * SendGrid signs each webhook POST with Ed25519. The signature covers the
14 * timestamp + raw request body. The public key is set in the SendGrid
15 * dashboard (Settings → Mail Settings → Event Webhook → Signature
16 * Verification → "Copy Public Key") and must be placed in the env var
17 * `SENDGRID_WEBHOOK_PUBLIC_KEY`.
18 *
19 * Rollout-safe: if the env var is missing the middleware logs a warning
20 * and lets the request through, so we can deploy the middleware before
21 * the key is configured. Once the key is set, invalid signatures return
22 * 401.
23 */
24class VerifySendgridSignature
25{
26    public function handle(Request $request, Closure $next): Response
27    {
28        $publicKey = env('SENDGRID_WEBHOOK_PUBLIC_KEY');
29
30        if (empty($publicKey)) {
31            Log::channel('email_log')->warning(
32                'SendGrid webhook received without SENDGRID_WEBHOOK_PUBLIC_KEY configured. Skipping signature verification.'
33            );
34
35            return $next($request);
36        }
37
38        $signature = $request->header('X-Twilio-Email-Event-Webhook-Signature');
39        $timestamp = $request->header('X-Twilio-Email-Event-Webhook-Timestamp');
40
41        if (! $signature || ! $timestamp) {
42            Log::channel('email_log')->warning('SendGrid webhook missing signature/timestamp headers.');
43
44            return response(['error' => 'Missing signature headers'], 401);
45        }
46
47        // SendGrid signs: timestamp + raw body
48        $payload = $timestamp . $request->getContent();
49
50        // Keys from SendGrid dashboard are base64-encoded DER. Strip the 12-byte
51        // DER prefix (0x302a300506032b6570032100) to get the raw 32-byte key.
52        $keyRaw = base64_decode($publicKey);
53        if (strlen($keyRaw) === 44) {
54            $keyRaw = substr($keyRaw, 12);
55        }
56
57        $signatureRaw = base64_decode($signature);
58
59        try {
60            $valid = sodium_crypto_sign_verify_detached($signatureRaw, $payload, $keyRaw);
61        } catch (\Throwable $e) {
62            Log::channel('email_log')->error('SendGrid signature verification threw: ' . $e->getMessage());
63
64            return response(['error' => 'Signature verification failed'], 401);
65        }
66
67        if (! $valid) {
68            Log::channel('email_log')->warning('SendGrid webhook rejected: invalid signature.');
69
70            return response(['error' => 'Invalid signature'], 401);
71        }
72
73        return $next($request);
74    }
75}