Skip to content

2.1.0

Choose a tag to compare

@goldnead goldnead released this 04 Sep 13:23
· 46 commits to main since this release

Fixed — a multi-brand install answered every inbound delivery with 404

The endpoint was there, enabled, with the right handle and the right secret.
Nothing could see it.

InboundEndpoint is brand-scoped, and the brand scope fails closed: with no
current brand a query returns no rows. Every other route in this family gets its
brand from something the caller carries — a Control Panel session, a bearer
token, a link token. A webhook sender carries none of those. It is Scaleway or
Stripe or n8n, and it holds a URL. So the lookup in InboundWebhookController
ran with no brand, the scope excluded every row before the query was made, and
the controller answered 404 Endpoint not found or disabled — the same answer a
misspelt handle gets, which is why it read like an operator mistake rather than
a total outage of the feature.

The brand now comes out of the URL, which is the only thing the sender holds:

https://your-site.test/webhooks/inbound/{brand}/{handle}

ResolveInboundBrand sets it for the whole request, not just around the
lookup. The action at the end of the pipeline writes rows, and those are stamped
with the current brand at write time; resolving the endpoint under one brand and
writing its results under another would have been the same bug in a more
expensive form. The previous value is restored on the way out, so a long-lived
process cannot hand one delivery's brand to whatever runs next.

The short URL keeps working and resolves to the default brand — where
the brand-scoping migration put every endpoint that predates brands, so an
install that switches multi_brand on keeps the senders that worked before it
switched. It deliberately does not search the other brands for a matching
handle: handle is unique per brand, not globally, so that search would have to
guess the moment two brands pick the same name, and a webhook config is a
destination plus the credential that authenticates it. A brand segment naming a
brand that does not exist gets the same 404 as an unknown handle, so the URL
cannot be used to enumerate an installation's brands.

ResolveInboundBrand is prepended to the inbound stack by
inboundMiddleware() and is not part of the configurable list. An install that
published config/webhook-manager.php before this release has the old array
frozen in its own file and would otherwise have upgraded into the same outage
without a single line of warning.

Are you affected? Only with brand-context.multi_brand on. Single-brand
installs — every install before brands, and the overwhelming majority since —
were never affected: the scope is a no-op there and the short URL behaves
exactly as it always did. One cosmetic change does reach them: the Control
Panel now prints the brand-qualified URL everywhere, so an endpoint that used
to be shown as /webhooks/inbound/orders is shown as
/webhooks/inbound/default/orders. Both work. There is no reason to go and
reconfigure a sender.

Why no test caught it. Every inbound test ran single-brand, where the scope
does nothing, and every brand-isolation test asserted at the model layer without
making a request. The outage sat in the gap between them.
InboundEndpointIsReachableUnderMultiBrandTest is that gap, closed.

Removed — SubstituteBindings is no longer in the inbound stack

It was there "so that a future bound route parameter resolves". There is no
bound parameter on any inbound route: {brand} and {handle} are generic
names, deliberately unbound, and RouteParameterCollisionTest keeps them that
way. So it resolved nothing, and it was not free — a Route::bind('brand')
registered by the host app or any sibling addon applies to every route with
that parameter name, this one included, and a binding that aborts when it finds
nothing (which is what a model binding does) ended the delivery. Measured, not
reasoned: with it in the stack, a hostile binding turned a correctly signed
delivery into a 404. InboundEndpointDefaultsAndRouteOrderTest registers that
binding and sends a real delivery through it.

If you published config/webhook-manager.php before this release, your own
copy still lists SubstituteBindings under inbound.middleware and keeps that
exposure. Removing the line is the whole fix.

Fixed — the Control Panel printed a URL that was not the one being routed

Two independent reasons, both of which handed the operator a URL that 404s. The
listing and the edit page built the URL from path, a free-text field, while
the router has always matched on handle — equal until someone edits path.
And neither knew about the brand segment.

The URL on the listing — the one with the copy button, the one that gets pasted
into a sender's webhook field — is now built in PHP by
WebhookManagerServiceProvider::inboundPath() and printed as it arrives. The
edit page still composes the same shape in the browser, because it previews the
URL live while the handle is being typed and there is no saved endpoint to ask
about yet.

inboundPath() also refuses to print a brand segment the router could not
match. Nothing validates a brand handle on the way in — the model is unguarded
and the column carries only a unique index — so a brand called Chor.de is
possible, and printing it would have recreated this exact bug one level up.

Fixed — an unauthenticated request could write a log row per attempt

ResolveInboundBrand writes its two log lines before the endpoint is looked
up, and therefore before the rate limiter, which lives in the pipeline and needs
an endpoint to know its budget. SystemLogger writes a database row. Ungated,
POST /webhooks/inbound/anything/atall was one INSERT per request, from any
address, with no signature, no endpoint and no brand. Both lines are now written
at most once per key per hour — a log line exists to be read by a person, and a
cache miss on a hostile key costs a cache write instead of a table row. The same
gate keeps inbound_brand_defaulted from writing a row for every legitimate
delivery on the backwards-compatible short URL.

Fixed — the replay guard could be beaten by the case it exists for

ReplayProtectionService::check() was seen() followed by remember(), with a
window between the two. The delivery that lands in that window is precisely the
one this class is for: a sender that did not get its answer in time and sends
the same delivery again immediately. Both requests read "not seen", both
proceeded, and the configured action ran twice — under concurrency the guard did
nothing at all, and no sequential test could ever show it. The claim is one
conditional Cache::add() now, so exclusivity is the store's (SET NX on
Redis, add on Memcached, a unique key in the database store).

A false from add() means one of two things, and they are not the same: the
key is already there, or the store keeps nothing at all. On the null driver
only the second is ever true, so answering 409 to every false would have
turned a cache setting into a permanently dead endpoint — a worse failure than
the missing guard. One read on the rejection path tells them apart.

Changed — new inbound endpoints have replay protection on

replay_protection_enabled defaults to true for endpoints created from 2.1.0
onwards. Every serious sender retries a delivery it could not confirm, and a
timeout looks exactly like a failure from the outside; without the duplicate
guard the configured action simply runs twice. Existing endpoints keep whatever
they were saved with, and the switch is still in the Control Panel for the cases
where the action is genuinely idempotent.

Added — an HMAC endpoint that does not require a timestamp now says so

inbound_signature_without_timestamp, at most once an hour per endpoint, in the
CP log. Not once per delivery: the condition is a property of the endpoint's
configuration and does not change between deliveries, and a line per delivery
would bury the log an operator uses to find actual trouble.

Without require_timestamp an HMAC signature covers a body and nothing else,
and a signature that says nothing about when it was made never expires: anyone
who has ever seen one valid delivery can send it again next year and it
verifies. The replay cache closes its own window — ten minutes by default — and
not one second more; it is a duplicate guard, not an expiry.

It is not switched on for you. A sender that does not send the timestamp header
would start failing on upgrade, and this package does not get to make that
decision for a running integration. So it is said out loud instead, where the
operator reads the rest of that endpoint's traffic.