[2.x] feat: stop auth extensions having to fork core internals - #5028
Open
novacuum wants to merge 4 commits into
Open
[2.x] feat: stop auth extensions having to fork core internals#5028novacuum wants to merge 4 commits into
novacuum wants to merge 4 commits into
Conversation
`Extend\Middleware::insertBefore()` and `insertAfter()` located their anchor with `array_search()` and passed the result straight to `array_splice()`. When another extension had already replaced or removed that anchor, the search returned `false`, `array_splice()` read it as offset 0, and the middleware was silently placed at the very front of the stack — ahead of `HandleErrors`, where nothing it throws can be reported. Fail at boot with a message naming the frontend, the anchor and the middleware being inserted instead. `remove()` keeps its no-op semantics for an absent entry; only insertions need an anchor to exist.
Every request parameter that ends up in a `Location` header is an open-redirect vector, but core validated them in two places with copy-pasted private helpers and left the third to callers: `ResponseFactory::make()` documents `$returnTo` as "must be validated by the caller" while shipping nothing to validate it with. Extensions have filled that gap with their own comparisons against hard-coded host lists. `Flarum\Http\ReturnUrlValidator` covers both shapes core accepts: `validate()`/`sanitize()` for an absolute URL checked against the forum host plus `redirectDomains`, as the logout endpoints accept via `?return=`, and `validatePath()` for the same-origin relative path `ResponseFactory` expects. Both logout controllers now delegate to it. Their `sanitizeReturnUrl()` and `getAllowedRedirectDomains()` remain as protected shims, and behaviour is unchanged — `validate()` still rejects relative paths, which have no host to check and are indistinguishable from a protocol-relative reference off-site.
`Rememberer::remember()` hardcoded `RememberAccessToken::rememberCookieLifeTime()` regardless of which token it was handed, and that method resolved `self::$lifetime` rather than `static::$lifetime`. A subclass declaring its own `$lifetime` therefore changed when the token stopped being valid but not when the cookie expired, leaving the browser presenting a dead token for up to the five-year default. Read the lifetime off the token instance's class and resolve it late, so the cookie expires with the credential it carries. Relevant to any integration that matches a session to an external authority's — an OAuth login inherits `session_remember` today, and its provider's session is rarely five years long.
Resolving the actor from the session was private, so an extension that needs to check a resolved actor against an external authority — an identity provider that may have revoked the upstream session since this one was established — had to replace the whole middleware and reimplement the body, including the session-invalidation path taken when a token is no longer valid. That copy then silently diverges from core. Make it protected so such an extension can override it, call `parent::getActor()`, and act on the result.
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.
Fixes four places where an extension that authenticates against an external identity provider has to copy or replace core internals to do so.
Changes proposed in this pull request:
One commit per gap; they're independent and can be dropped individually.
1.
Extend\Middlewaresilently mis-inserts when its anchor is gone (fix)array_search()returnsfalsewhen the anchor was already replaced or removed by an earlier extender, andarray_splice()readsfalseas offset 0. SoinsertAfter(AuthenticateWithSession::class, …)lands the middleware at the very front of the stack — ahead ofHandleErrors, where nothing it throws can be reported — and nothing says so.This is reachable with two ordinary extensions: one
replace()s a middleware, the otherinsertAfter()s the same class, and whichever loads second gets moved to position 0. Insertions now throw with the frontend, the anchor and the middleware named.remove()keeps its no-op semantics for an absent entry; only insertions need an anchor to exist. Comparison is also now strict.2. No return-URL validator, in three different flavours (
feat)ResponseFactory::make()documents$returnToas "must be validated by the caller before being passed here" and ships nothing to validate it with, whileLogOutControllerandLogOutViewControllereach carry an identical private copy of a host allow-list check. Third-party OAuth controllers have filled the gap independently — one accepts relative paths only, another compares against a hard-coded host list — so there are currently three unrelated security models for the same parameter.Flarum\Http\ReturnUrlValidatorcovers both shapes core accepts:validate()/sanitize()for an absolute URL checked against the forum host plusredirectDomains, which is what?return=on the logout endpoints takes, andvalidatePath()for the same-origin relative pathResponseFactoryexpects. Both logout controllers delegate to it and keepsanitizeReturnUrl()/getAllowedRedirectDomains()as protected shims.Behaviour is deliberately unchanged:
validate()still rejects relative paths, because they carry no host to check and a browser resolves//hostoff-site.3. The remember cookie ignores its token's lifetime (
fix)Rememberer::remember()hardcodedRememberAccessToken::rememberCookieLifeTime()regardless of the token it was handed, and that method resolvedself::$lifetimerather thanstatic::$lifetime. A subclass declaring its own$lifetimetherefore changed when the token stopped being valid but not when the cookie expired, so the browser kept presenting a dead token for up to the five-year default. Two bugs pointing the same way, and between them a documented-looking seam that silently does nothing.The lifetime now comes off the token instance's class and resolves late. Relevant to any login that inherits
session_rememberfrom an external authority whose own session is nowhere near five years long — which is every OAuth login today, sinceResponseFactorymints aRememberAccessTokenfor all of them.4.
AuthenticateWithSession::getActor()is private (feat)An extension that needs to check a resolved actor against an external authority — an identity provider that may have revoked the upstream session since this one was established — has to
replace()the whole middleware and reimplement the body, including the$session->invalidate()/regenerateToken()path taken when a token is no longer valid. That copy then diverges from core silently on every change to it. Nowprotected, so such an extension overrides it, callsparent::getActor(), and acts on the result.Reviewers should focus on:
2.xis bug-fix-only until GA. (1) and (3) are fixes; (2) and (4) add surface. Say the word and I'll pull the additive pair onto a separate PR for 2.1 and leave the two fixes here.LogOutControllerandLogOutViewControllerconstructor signatures change —Configout,ReturnUrlValidatorin, sinceConfigwas only there for the allow-list. Both are container-resolved and nothing in the monorepo instantiates them directly, and the protected methods still work for subclasses, but it is a signature change.ResponseFactory::make()should validate$returnToitself rather than documenting that its caller must. I left the contract as-is because existing OAuth extensions already validate before calling and double-validation would silently narrow what they accept, but "core hands you a documented obligation and no tool" is arguably the wrong default and this PR only fixes the missing tool.?return=. They reject one today —validate()preserves that — but a same-origin path is the obvious common case and is strictly safer than the absolute URLs they do accept. Out of scope here; flagging it because the newvalidatePath()makes it a two-line change if you want it.Screenshot
Not applicable — no user-facing or visual change.
Necessity
flarum.http.session_actor_resolverstagged list;protectedis the smaller change and can grow into one later if a second use case turns up. For (1) see the first bullet above.Rememberer, and two controllers. None is reachable from an extension without replacing or copying the class.Confirmed
yarn testinjs/). — no frontend changes.composer test). — partially: I ran the unit suite only (phpunit -c tests/phpunit.unit.xml), not the integration suite, which needs a database I don't have provisioned.On the tests
Three new unit files, 20 tests:
tests/unit/Extend/MiddlewareTest.php(6) —insertBefore/insertAfterplacement, the throw for a missing anchor in both directions, the two-extender case where onereplace()s the anchor the other inserts against, and thatremove()is still a no-op for an absent entry. Core had no unit coverage of this extender; the integrationextenders/MiddlewareTestcovers placement through a real request but not the missing-anchor path.tests/unit/Http/ReturnUrlValidatorTest.php(12) — the allow list, acceptance and rejection for both contracts, unparseable and empty input, the explicit fallbacks, and a CRLF payload againstvalidatePath().tests/unit/Http/RemembererTest.php(2) — the default lifetime and a subclass lifetime, which is the one that fails before this change.Required changes:
ReturnUrlValidatorshould be mentioned wherever$returnTois documented for OAuth authors — the 2.0 upgrade guide currently tells them to validate it without saying with what. Happy to open that once the shape here is settled. While checking, I also noticedredirectDomainsisn't documented inconfig.mdat all, and it's the allow list this validator reads.