Skip to content

feat!: accept a callback only on the provider's callback path - #66

Open
turegjorup wants to merge 6 commits into
developfrom
feature/constrain-supports-to-callback-path
Open

feat!: accept a callback only on the provider's callback path#66
turegjorup wants to merge 6 commits into
developfrom
feature/constrain-supports-to-callback-path

Conversation

@turegjorup

@turegjorup turegjorup commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Implements the plan for issue #63. Stays in the unreleased 6.0, which is being held.

Changes

  • supports() requires state, code and a path matching one of this authenticator's providers.
  • OpenIdConfigurationProviderManager::getRedirectUriPaths() derives the path per provider from callback_path, the generated redirect_route (as ABSOLUTE_PATH), or the path of redirect_uri — normalised, and memoized.
  • New per-provider callback_path, for proxies that rewrite the path.
  • Each provider must now declare redirect_uri, redirect_route or callback_path (compile-time).
  • getSupportedProviderKeys() to narrow an authenticator to its own providers; defaults to all, so existing multi-authenticator firewalls are untouched.
  • createTargetPathRedirect() for returning the user to the page that sent them to log in (Part B).
  • ?target_path= on the login route, so a login link on a public page can name where to return to.
  • ADR 003, upgrade notes, README sections, changelog.

Why

state and code alone made every URL behind the firewall a callback. Combined with #62's fail-closed behaviour, an unauthenticated caller could raise a 500 on any URL by appending two query parameters. Now a forged callback is the firewall's business again — an entry-point redirect — while a real callback that fails still fails closed.

Decisions the plan left open

  • Hard compile-time requirement for a callback target per provider, per the plan's recommendation. A provider without one can never recognise a callback, and "matches every path" is the defect being removed.

  • redirect_route generated as ABSOLUTE_PATH, so a route's host or scheme requirements do not enter the comparison. Noted in the ADR as unsupported.

  • No logging from supports(): it runs pre-authentication on every request, so a log call there amplifies anyone sending traffic.

  • ?target_path= on the login controller is included after all. The plan deferred it, but neither stated obstacle was one: the firewall-name problem disappears if the value lives under a bundle-private session key that createTargetPathRedirect() already reads, and the open-redirect risk is validation, which is the work rather than a reason to postpone. A page the firewall denied takes precedence, since that is what the user was actually stopped from reaching, and a value that is not a path within the application is dropped with a warning — one leading /, no backslash, no ://, no control characters.

    Deep links to protected pages never needed it: the firewall saves the requested page when the entry point fires, and testADeepLinkSurvivesTheLoginRoundTrip pins the whole trip. ?target_path= covers only the case where nothing was denied, so nothing was saved.

Validated on devops_itksites

Installed from disk, artifact verified in vendor/ first:

Check Result
Derived path, real redirect_uri {"azure_az":"/openid-connect/generic"}
Callback on that path (bad state) 500 — reaches validateClaims(), still fails closed
Stray callback on /admin 302 -> /openidconnect/login/azure_az — the #63 fix
itksites' own test suite 52 tests, 111 assertions, OK
cache:clear dev + prod both OK
?target_path=/admin/server remembered
?target_path=https://evil.example.org/phish dropped
?target_path=//evil.example.org dropped
?target_path=/admin\evil dropped

On the risk of a silent break, since this is the failure mode I flagged when scoping #63: a redirect_uri that is not a real URL derives a path nothing matches, so callbacks would stop being recognised. Checked what that actually does — with the placeholder value, the authorize request sends redirect_uri=AZURE_AZ_APP_REDIRECT_URI to the identity provider, which rejects it, so login was already broken in that deployment before this change. The genuine new exposure is a rewriting proxy, where redirect_uri is valid and registered but the internal path differs; callback_path is for exactly that and the upgrade note says so.

Tests

241 tests, 100% lines, 100% covered MSI (367 killed, none escaped), PHPStan clean, green on both ends of the supported range (framework-bundle 8.x and 6.4.13).

Worth calling out two fixture changes:

  • FailedCallbackDoesNotLoopTest pointed at /protected?state=…&code=…, which is no longer a callback, so the loop assertions would have passed vacuously. Its own guard caught that — it is retargeted at /callback_uri, which needed a route so the request reaches the firewall.
  • security_consumer.yml gained access_control, without which nothing demanded authentication, no entry point fired, and the two new tests asserted against a 200.

supports() matched state and code on any path, so every URL behind the firewall was a
potential callback. Since 6.0 fails closed, that meant an unauthenticated caller could
raise a 500 on any URL by appending two query parameters. Requiring the provider's
configured callback path leaves a forged callback to the firewall's ordinary handling,
without weakening fail-closed behaviour for real ones.

Paths are derived from configuration and memoized rather than read off a provider
instance: building one pulls in discovery, an HTTP client and a cache pool, and this
runs on every request. Nothing consults the session — that would start one for
anonymous traffic, and a lost session would put the redirect loop back.

callback_path covers proxies that rewrite the path, and each provider must now declare
one of redirect_uri, redirect_route or callback_path, since without one it could never
recognise a callback. getSupportedProviderKeys() defaults to every provider, so
existing multi-authenticator firewalls are unaffected.

Also adds createTargetPathRedirect(), in the same file, for returning the user to the
page that sent them to log in.
Also corrects the ADR index, which still listed 002 as Draft, and a stale
parent::__construct($providerManager, $requestStack) in the README example.
@turegjorup turegjorup self-assigned this Aug 20, 2026
@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (876f7ea) to head (f4d76a0).

Additional details and impacted files
@@             Coverage Diff             @@
##             develop       #66   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity       143       178   +35     
===========================================
  Files             14        14           
  Lines            637       730   +93     
===========================================
+ Hits             637       730   +93     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…ts limits

The kernel test covered only the firewall's half — that the requested page is saved.
This adds the whole trip: a link to a protected page is denied, the user comes back
from the identity provider, and lands on that page rather than a default. The fixture
authenticator now uses createTargetPathRedirect(), as the README tells consumers to.

The deep link needed a route. Routing runs before security (RouterListener at priority
32 on kernel.request, the firewall at 8), so an unrouted path is a 404 before the
firewall is reached: no entry point, nothing saved, and a test that measures 404
handling instead of the round trip. Documented, since it is also the answer to why a
link to a non-existent page does not come back after login.
createTargetPathRedirect() could only return users to a page the firewall had denied.
A login link on a public page — a header "Log in" button — denies nothing, so there was
nothing saved and those users landed on the application's fallback. They can now name
the destination: ?target_path=/admin/reports.

Stored under a bundle-private session key, not TargetPathTrait's, which is keyed by
firewall: LoginController has no firewall name, and writing there would put a value in
the firewall's own record that the firewall never saved. A page the firewall denied
still wins, since that is what the user was actually stopped from reaching, and both
keys are cleared on use.

The value reaches a Location header, so it is validated as a path within the
application — one leading slash, no backslash, no scheme separator, no control
characters — and dropped with a warning otherwise. Correcting one would be guessing at
intent on a security boundary.
@turegjorup

Copy link
Copy Markdown
Contributor Author

Findings

  1. (Design issue — should block) The path comparison basis is inconsistent, and the X-Forwarded-Prefix claim in README/UPGRADE is inverted. I verified against Symfony source: preparePathInfo() strips getBaseUrlReal() — the real base URL, not the trusted-prefix-augmented getBaseUrl() — so getPathInfo() never includes an X-Forwarded-Prefix. Meanwhile UrlGenerator with ABSOLUTE_PATH prepends context->getBaseUrl(), which does include the trusted prefix during a proxied request. Consequences:

The documented claim "a proxy sending X-Forwarded-Prefix with trusted proxies configured needs no callback_path — the path then already matches" is exactly backwards. With the header trusted, the redirect_uri-derived path is /prefix/auth/callback while getPathInfo() stays /auth/callback — still a mismatch, and now the redirect_route derivation also mismatches because generation picks up the prefix. The plan made this same claim (A2), so the bug was inherited, not introduced.
Apps deployed in a real subdirectory (non-empty getBaseUrlReal(), e.g. shared hosting) break for both redirect_uri and redirect_route providers: derived /app/callback vs pathInfo /callback.

Suggested fix: compare derived paths against $request->getBaseUrl().$request->getPathInfo() (normalized) instead of pathInfo alone. That makes every case line up: root deployments unchanged, subdirectory deployments work, the X-Forwarded-Prefix claim becomes true, and the no-header rewriting proxy still needs callback_path as designed. One residual to note in the ADR: redirect_route derivation is memoized with the first request's RequestContext, so mixed proxied/direct traffic with differing base URLs can't be satisfied by one frozen value — a limitation worth a sentence.

  1. (Should fix) Orphaned docblock in LoginController. The new rememberNamedTargetPath() was inserted between checkClientSecretExpiry()'s docblock and its method — the expiry docblock now dangles as a second docblock above the new method, and checkClientSecretExpiry() has none. Ironic given bf87a0b on the feat!: require client_secret_expires_at #65 branch was literally "reunite a docblock with its method."

  2. (Should fix) Empty callback_path at runtime silently becomes /. Configuration deliberately lets '' through for the env-var compile fixture — correct — but derivePath() only checks isset(). An env var that resolves to empty at runtime therefore yields normalizePath('') = /, making the site root the callback path and shadowing the redirect_uri fallback. Guard with isset(...) && '' !== $options['callback_path'] so an empty value falls through to the next derivation instead.

  3. (Minor) Stale named target replay. rememberNamedTargetPath() returns early when the parameter is absent, so a target from an earlier abandoned ?target_path= link survives in the session and is consumed by a later plain login. The value is validated so the risk is contained, but clearing the key when the parameter is absent ("last login link wins") would be cleaner.

  4. (Minor) ADR 003 structure. "Returning to a page the firewall never saw" sits after References and documents a second decision. If B3 stays in this PR, either move that section above References or give the target-path feature its own short ADR — which would also make the scope expansion from finding 4 above explicit in the record.

getPathInfo() has both a subdirectory deployment's base path and any trusted
X-Forwarded-Prefix stripped out of it: Request::preparePathInfo() subtracts
getBaseUrlReal(), while getBaseUrl() is the trusted prefix plus that. The configured
paths contain them — a redirect_uri is the URL the identity provider was given, and
UrlGenerator prepends the routing context's base URL, which RequestContext::fromRequest()
takes from getBaseUrl(). So comparing path info alone rejected every callback both in a
subdirectory deployment and behind a prefix-announcing proxy, and the README claimed the
opposite of what happened.

Derived paths are now memoized per routing-context base URL rather than once, so a
service seeing both proxied and direct traffic is not frozen to whichever came first.

Also: an empty callback_path no longer normalizes to "/" and shadows redirect_uri; a
plain login link clears a target left by an abandoned one; the expiry docblock is back
on its own method; and ADR 003 documents the comparison basis rather than trailing a
second decision after References.
@turegjorup

Copy link
Copy Markdown
Contributor Author

All five fixed. Finding 1 was right in every particular, and I verified each mechanic in the installed source before changing anything:

  • Request::preparePathInfo() subtracts getBaseUrlReal() (Request.php:2029), so path info carries neither a subdirectory base path nor a trusted prefix.
  • getBaseUrl() returns $trustedPrefix.$this->getBaseUrlReal() (Request.php:931).
  • RequestContext::fromRequest() takes $request->getBaseUrl() (RequestContext.php:80), and UrlGenerator prepends $this->context->getBaseUrl() for ABSOLUTE_PATH (UrlGenerator.php:276).

So both derivations carried the prefix while the comparison basis did not, and the README claimed the reverse of what happened. Now comparing getBaseUrl().getPathInfo(), which lines every case up:

Deployment Configured Request Match
root /callback_uri `` + /callback_uri yes
subdirectory /app/callback_uri /app + /callback_uri yes
trusted X-Forwarded-Prefix /prefix/callback_uri /prefix + /callback_uri yes
rewriting proxy, no header /prefix/callback_uri `` + /callback_uri no — callback_path, as designed

Covered by testTheCallbackPathIncludesTheBaseUrl over those four rows. It uses a small RequestWithBaseUrl fixture rather than Request::setTrustedProxies(), to keep process-wide state out of the suite.

On the memoization limitation you flagged: rather than document it, getRedirectUriPaths() is now memoized per routing-context base URL, since that is exactly what varies between proxied and direct traffic. testRoutePathsAreMemoizedPerBaseUrl moves the context between '' and /prefix and back, asserting the derived path follows.

The other four:

  • 2. Orphaned docblock — reattached to checkClientSecretExpiry(). Same mistake as bf87a0b, same cause: my scripted insert anchored on the signature rather than the docblock. Twice is a pattern, so I checked the whole diff for others rather than only this one.
  • 3. Empty callback_path — guarded with '' !== $options['callback_path'], so an environment variable resolving to nothing falls through to redirect_uri instead of making the site root the callback path. testAnEmptyCallbackPathFallsThroughToRedirectUri.
  • 4. Stale named target — a plain login link now clears the key, so the last login link wins. testAPlainLoginLinkForgetsAnEarlierTarget.
  • 5. ADR structure — the section sits above References, and the Decision list now records the comparison basis, which was the subtle part worth having in the record.

Separately, on @phpstan-ignore being a last resort: this PR adds none. It also removes the two that existed in the files it touches, by typing rather than suppressing — the manager test's helper and data provider now declare precise shapes, and default_providers_options is typed array{cacheItemPool?: CacheItemPoolInterface}, which is all it ever holds. PHPStan is clean without them, so that suppression in getProvider() had been avoidable.

273 tests, 100% lines, 100% covered MSI (409 killed, none escaped), PHPStan clean, green on framework-bundle 8.x and 6.4.13.

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.

2 participants