Plugin console guard + three security P1s - #2
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 invendor/provided it — so installing any plugin threwInvalidArgumentException: Target class [platform.auth] does not existon 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 withoutwebthere is no session andauth.cbox— which authenticates a session — could never pass. The rest mirrors the host's own console guard.Security
TRUSTED_PROXIES—.env.exampleshipped an empty string, soenv()returned''and no proxy was trusted, the opposite of the documented default. Collapses the per-IP webhook limiter into one global bucket.Gate
pint · PHPStan level max (0 errors) · 1026 tests ·
composer audit— green. Every fix verified by removing it and watching its test fail.