Skip to content

Plugin console guard + three security P1s - #2

Merged
sylvesterdamgaard merged 2 commits into
mainfrom
fix/plugin-console-guard
Jul 27, 2026
Merged

Plugin console guard + three security P1s#2
sylvesterdamgaard merged 2 commits into
mainfrom
fix/plugin-console-guard

Conversation

@sylvesterdamgaard

Copy link
Copy Markdown
Contributor

Plugins could not boot

All six plugins gate their console routes on platform.auth. This app registered eight aliases; that was not one of them, and nothing in vendor/ provided it — so installing any plugin threw InvalidArgumentException: Target class [platform.auth] does not exist on every console request. No plugin test stubs the name, so nothing caught it.

Registered as a group, not an alias: a plugin publishes routes with a bare loadRoutesFrom() and inherits no group, so without web there is no session and auth.cbox — which authenticates a session — could never pass. The rest mirrors the host's own console guard.

Security

  • TRUSTED_PROXIES.env.example shipped an empty string, so env() returned '' and no proxy was trusted, the opposite of the documented default. Collapses the per-IP webhook limiter into one global bucket.
  • Portal plan change — a customer with a valid portal token could move onto any plan by key, including archived tiers and other products' plans.
  • Hosted session tokens — the idempotency replay store persisted live bearer tokens in plaintext for 72h, defeating the digest-only design.

Gate

pint · PHPStan level max (0 errors) · 1026 tests · composer audit — green. Every fix verified by removing it and watching its test fail.

All six plugins gate their console routes on a `platform.auth` middleware name.
This app registered eight aliases and that was not among them, and nothing in
vendor/ provided it either — so installing any plugin raised
`InvalidArgumentException: Target class [platform.auth] does not exist` on every
console request. The entire commercial tier was unreachable in a fresh
composition, and since no plugin test stubs the name, nothing anywhere caught it.

Registered as a GROUP, not an alias. A plugin publishes its routes with a bare
`loadRoutesFrom()`, so it inherits no group of its own: without `web` there is no
session, and `auth.cbox` authenticates a Cbox ID session, so it could never pass.
Nested group names are expanded by the router, so `web` contributes the session,
cookie and CSRF stack exactly as the host's console routes receive it.

The remainder mirrors routes/web.php's console group, so a plugin page is
protected identically to a first-party one: authenticated principal, allowlisted
operator org, plane resolved, action audited. `billing.permission` is deliberately
excluded — the slug differs per plugin surface, so each plugin declares its own
alongside this group.

The test declares a plugin-shaped route and asserts the guard both RESOLVES and
guards; without the first assertion the name could be registered as something
inert and still pass. Removing the registration fails all three with the exact
error a plugin install hits.
TRUSTED PROXIES. .env.example shipped `TRUSTED_PROXIES=` — an empty STRING, not an
absent key — so env() returned '' and TrustProxies trusted nothing, the opposite of
the RFC1918 default documented two lines above it. `composer setup` copies the file
verbatim. Every request then appears to originate at the ingress, collapsing the
per-IP webhook limiter into ONE global bucket: a few hundred junk POSTs a minute to
/webhooks/stripe can starve every real settlement callback, so invoices stop being
marked paid. Commented out, so the config default applies.

PORTAL PLAN CHANGE. The portal listed only active plans but validated none of it,
and SubscriptionService::changePlan() checks no target either — so a customer with a
legitimate portal token could name ANY plan by key and be moved onto it. Plan keys
are not secret; they appear in the pricing-table JSON, the entitlements API and old
invoices. Now enforces the two rules already stated elsewhere in the codebase: an
inactive plan is Legacy (a valid transition SOURCE, never a target), and the target
must belong to the same product — which the management API checked and the portal
did not. Resolution order was flipped so the subscription is known before a plan is
judged against it; PHPStan caught that the old order passed a nullable.

HOSTED SESSION TOKENS AT REST. /checkout-sessions and /portal-sessions return a URL
embedding the session's plaintext bearer token, and both carry the idempotency
middleware, which persists the entire 2xx body for 72h. BillingSessionService stores
only the token's SHA-256 digest specifically so "a DB dump never yields a live
token" — the replay copy put it straight back. Within the TTL that URL is full
authority over the customer's subscription and vaulted cards. Both now set
X-Idempotency-Redact, the mechanism LicenseController already used for its signed key.

All three regression-tested, and each test verified to fail with its fix removed.
@sylvesterdamgaard
sylvesterdamgaard merged commit d363cf4 into main Jul 27, 2026
2 checks passed
@sylvesterdamgaard
sylvesterdamgaard deleted the fix/plugin-console-guard branch July 27, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant