Skip to main content
Laravel, shipping fast.

send() is short, and most of its lines are there because of an attack.

// app/Listeners/DeliverLicenseWebhook.php
private function send(
    Consumer $consumer,
    array $payload,
): ClientResponse {
    $body = json_encode($payload, JSON_THROW_ON_ERROR);
    $timestamp = now()->timestamp;

    $signature = hash_hmac(
        'sha256',
        "{$timestamp}.{$body}",
        $consumer->webhook_secret,
    );

    $header = "t={$timestamp},v1={$signature}";

    return Http::timeout(15)
        ->withoutRedirecting()
        ->withHeader('X-Signature', $header)
        ->withBody($body, 'application/json')
        ->post(PublicUrl::resolve($consumer->webhook_url));
}

The Signature

Once a webhook exists, anyone who learns a subscriber’s URL can send it a payload that looks like yours. The subscriber needs a way to check. The standard answer is a secret both sides know: you sign the body with it and send the signature in a header.

The timestamp is inside the signed string so that a captured request can’t be replayed next week: the subscriber rejects anything older than a few minutes. The secret is generated by you, shown to the consumer once, and stored encrypted and hidden, as the model above showed.

The subscriber’s side is one comparison, and it is the line people get wrong:

// Bad: leaks timing
if ($received === $expected) {
    // ...
}

// Good: constant time
if (hash_equals($expected, $received)) {
    // ...
}

=== stops at the first byte that differs, so an attacker who measures response times closely enough can guess a signature one character at a time. hash_equals() takes the same time wherever the strings diverge. Put this in your webhook documentation. It is the subscriber’s code that has to get it right.

The Address

withoutRedirecting() and PublicUrl::resolve() protect you. A webhook URL is an address a consumer chose, and your server is about to send a request to it from inside your network. Point it at http://169.254.169.254/ and, on most cloud platforms, your server fetches its own credentials. Point it at http://localhost:6379 and it talks to your Redis. This is server-side request forgery, and a webhook is the most common way to build it by accident.

Validate the URL when it is saved. The rule from Chapter 2 gained max:255 and new PublicUrl in the listing above, so it accepts only HTTPS and only hosts that resolve to public addresses.

Check again when it is used. DNS can change after the URL was saved, so PublicUrl::resolve() looks the host up again at delivery time and throws if any address it resolves to is private or reserved.

Don’t follow redirects. Without withoutRedirecting(), a perfectly public URL can answer with a redirect to an internal one, and both checks were for nothing.

Chapter 19 shows PublicUrl and is honest about what it can’t do. If webhooks are a large part of your product, send them through a proxy whose only job is to refuse internal destinations, and treat the checks here as the second line.

The audio could not be loaded. Try again in a moment.