Skip to main content

Security

Webhook URLs are attacker-influenced, so every endpoint is validated when it is registered and again immediately before each delivery, with the connection pinned to the validated IP so a rebinding DNS record cannot redirect it.

Private, loopback, link-local, unique-local, carrier-grade-NAT, multicast and cloud-metadata (169.254.169.254) addresses are refused, redirects are not followed, and TLS verification stays on.

The IPv6 transition prefixes are refused too — 6to4 (2002::/16), its deprecated relay anycast (192.88.99.0/24), Teredo (2001::/32) and both ORCHID blocks. Those matter more than their obscurity suggests: 6to4 and Teredo embed an IPv4 address, and not in the low bits where an IPv4-mapped address carries it. 2002:7f00:1:: is 127.0.0.1 wearing a different encoding, and an unwrapper that only knows the mapped, translated and compatible forms walks straight past it.

Beyond those, every block the IANA special-purpose address registries list as not globally reachable is refused, among them the segment routing identifiers an operator routes inside its own network (5f00::/16) and the dummy prefix (100:0:0:1::/64). The IETF protocol assignments block 2001::/23 is refused whole, like its IPv4 counterpart 192.0.0.0/24: the few addresses in it that IANA does call reachable are anycast services, relays and sinks, not webhook receivers.

IPv6 outside the global unicast space 2000::/3 is refused as a whole, because IANA assigns routable IPv6 from that block only and keeps the rest reserved. An IPv6 literal that embeds an IPv4 address is judged as that IPv4 address.

Signing secrets are stored encrypted at rest; inbound sensitive headers (Authorization, Cookie, plus your redact list) are masked before storage.

core.ssrf.allowed_hosts is an opt-out, not an allowlist​

A host you put on it skips DNS resolution, skips the private/loopback/metadata IP classification and skips the IP pin — so that host may resolve into your internal network and its DNS record may rebind between the check and the connection.

Use it only for a known internal endpoint whose risk you accept. The restrictive list is core.ssrf.blocked_hosts. An IP address on it is refused however a URL spells it (decimal, IPv4-mapped IPv6, uncompressed IPv6) and whatever name resolves to it; a name on it blocks that name, not another name for the same server. And core.ssrf.block_private_networks = false switches the guard off globally — leave it true.

The IP pin does not survive a proxy​

The SSRF guard vets and pins a destination IP for direct connections. A forward proxy resolves the hostname itself, so the pin is not enforced through it and the anti-rebinding guarantee becomes the proxy's responsibility — your proxy must enforce its own egress control. Leave core.egress.proxy unset unless the proxy does that.

Unset means direct. A proxy named in the worker's environment (HTTPS_PROXY, or HTTP_PROXY on the command line a queue worker runs on) is bypassed for deliveries and JWKS fetches, so it cannot carry them around the pin without anyone having configured it.

Fetching an outside URL under the same guard​

Core\Http\GuardedFetch gets a URL your application did not choose, an image in a received mail or a document a user linked to, with the guarantees a delivery has: the URL is vetted and the connection pinned to the vetted addresses, a redirect is handed back rather than followed, a proxy in the environment is bypassed, a compressed body is never inflated, and TLS stays verified.

use Pushery\Webhooks\Core\Http\Exceptions\BlockedDestination;
use Pushery\Webhooks\Core\Http\Exceptions\ResponseTooLarge;
use Pushery\Webhooks\Core\Http\GuardedFetch;

try {
$image = app(GuardedFetch::class)->get($url, maxBytes: 5_000_000, headers: ['Accept' => 'image/*']);
} catch (BlockedDestination|ResponseTooLarge) {
return null; // a private address, or a body past the limit
}

$image->status; // 200, or a 3xx whose Location you may vet and fetch in turn
$image->header('Content-Type'); // the first value, the name matched in any case
$image->body; // at most 5 000 000 bytes

The body is held to maxBytes. A response that declares a longer length is refused before its first byte, and one that sends without a length is cut off where it passes the limit, so a server that never stops sending costs the limit and not the timeout. Either way the fetch throws ResponseTooLarge and hands nothing of the body back. The same limit holds for a response faked with Http::fake(). connectTimeout and timeout default to 3 and 10 seconds.

The fetch asks the guard the delivery uses, so core.ssrf decides for both: with https_only a plain http:// URL is refused, and a host on allowed_hosts is fetched unpinned.

A signing secret does not leave the model by accident​

WebhookSubscription casts secret and previous_secret as encrypted, which protects them in the database. It does not protect the way out: Eloquent decrypts a cast attribute on the way into toArray(), so a serialized model used to carry the plaintext whsec_….

Both are now on the model's $hidden list. toArray(), toJson() and (string) $model — which is what Log::info($subscription) writes — no longer contain them.

Reading the secret still works exactly as the guides describe, because $hidden governs serialization and not property access:

$subscription->secret; // the plaintext, once

Worth knowing if you listen for WebhookEndpointRegistered to write an audit trail: that event carries the model, and serializing it there was the realistic way this leaked.

Rotating APP_KEY​

The endpoint secrets and the entries waiting in the transactional outbox are encrypted with APP_KEY. After a rotation Laravel still decrypts them with a key listed in APP_PREVIOUS_KEYS, but it encrypts under the new key only what it writes, and an endpoint's secret is written again only when the endpoint rotates it. Until then the old key cannot leave APP_PREVIOUS_KEYS: without it, every endpoint that has not rotated since can no longer sign, and an outbox entry can no longer be sent.

php artisan webhooks:reencrypt writes those values again under the current key:

  1. Set the new APP_KEY, move the old one to APP_PREVIOUS_KEYS, and deploy.
  2. Run php artisan webhooks:reencrypt. It reads each endpoint and each outbox entry again under a lock, so a secret rotated at the same moment is not overwritten, and it leaves updated_at as it was. A value that no configured key decrypts is named with its row, and the command exits with 1.
  3. Wait until the deliveries queued before the deploy have been sent. A queued delivery carries the values it signs and connects with, sealed under the key it was queued with, and the queue is out of the command's reach. The same holds for a failed webhook job you may still retry.
  4. Run the command again. Once it encrypts nothing more, remove the old key from APP_PREVIOUS_KEYS.

A host with a database per tenant runs the command inside its tenant loop, the way it runs the scheduled ones. The command covers what this package stores; encrypted columns of your own models need the same step.

Turning on search sends data out of the application​

The delivery body is governed everywhere else by the view-webhook-payload ability: without it, a reader sees the shape ([string], [int]) rather than the customer's email, and that decision is made per request, against the person looking.

An index has neither a request nor a person. It is one shared artifact, queried by everyone who can query it, held by a service whose retention, backups and access control are not this application's. So the ability cannot govern it, and the package does not pretend it can: with webhooks.search.enabled on, the index carries the event type, the url, the status, the owner pair and the timestamp — everything a search needs — and not the body.

Copying the body in is a separate, explicit decision:

// config/webhooks.php
'search' => [
'enabled' => true,
'index_payload' => true, // off unless you set it
],

Turn it on only where the index is protected as well as the database it mirrors. What lands there is the first payload_excerpt_chars characters of the payload verbatim — not redacted, whatever the ability says on screen.

A payload that was offloaded to a Storage disk is never read back for indexing, so a large body is not copied into the index either way.

Reporting a vulnerability​

Report vulnerabilities privately, per the security policy.