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:
- Set the new
APP_KEY, move the old one toAPP_PREVIOUS_KEYS, and deploy. - 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 leavesupdated_atas it was. A value that no configured key decrypts is named with its row, and the command exits with 1. - 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.
- 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.