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.
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. 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.
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.
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.