The Redirect-Target Contract

A ?returnUrl= (also spelled returnPath, returnTo) carries "where the user was going" across a flow that has to interrupt them — a sign-in, an onboarding form, a consent dialog. The portal has one rule for it, and it is short:

A redirect target is honoured only if it is LOCAL — a path on this host. Anything else falls back to the root.

ReturnUrlPolicy.Sanitize is that rule for the portal's server surface; the GUI has one implementation of the same rule for its client surface. Both are correct, both are tested, and neither is negotiable — an unvalidated redirect target is an open redirect, which is how a link that looks like the tail of our own login flow sends a freshly-signed-in user somewhere else.

The contract has two ends, and only one of them had a guard

The rule above is stated as a property of the sink: the thing that consumes an incoming target and redirects to it. RedirectSinksUseOnePolicyGuard enforces exactly that, and the invariant it pins is not "the policy is right" but "there is only one policy, and every sink uses it" — because the defect it was written for was three hand-written copies of the rule, two of them subtly weaker than the shared one.

What nothing enforced is the source: the thing that MINTS a target and hands it onward. And the two ends are not symmetric in what they cost when wrong:

Wrong sink Wrong source
What happens a non-local target is followed a non-local target is refused
Who is harmed the user, by an open redirect the flow, silently
How it surfaces a security finding "the feature intermittently does not work"

A wrong source produces no error anywhere. The sink refuses the target — doing its job — and the flow simply carries on without it. The GUI's helper for a target it will not keep returns nothing rather than substituting a destination, precisely so that a refused target does not become a redirect to a place the user did not ask for. So the parameter is omitted, the flow completes, and the user lands on /.

What that looked like in production

/authorize (the OAuth authorization endpoint MCP clients use) minted its return target as an absolute URL — scheme, host, path and query — and redirected to /login with it. Every sink downstream then refused it, correctly, because an absolute URL is not local. The consequence, end to end:

  1. An MCP client sends the user to /authorize?….
  2. The user is not signed in, so /authorize redirects to /login?returnUrl=<absolute url>.
  3. The login page will not keep a non-local target, so the provider link carries no returnUrl.
  4. The user signs in with Microsoft — successfully — and lands on /.
  5. The /authorize request is gone. No authorization code is ever issued.
  6. The client waits for an "authentication successful" that cannot arrive.

Nothing in that sequence logs an error. From the user's side a sign-in that worked did not connect anything, and from the client's side the credential looks expired — which is how it was reported (#5074: "Re-authenticating no longer helps").

The absolute form also bought nothing: /authorize and /login are the same origin, so the scheme and host were pure cost. OnboardingMiddleware, two files away, carries the same request the same way and gets it right — {Request.Path}{Request.QueryString}, with a comment saying why that is inherently local. One deviation, no guard to catch it.

The rule, for anyone minting one

Mint {Request.Path}{Request.QueryString}. Never {Request.Scheme}://{Request.Host}…. Then pass it through the shared policy anyway, so the intent is legible at the call site and the value is one the policy demonstrably keeps.

RedirectSourcesMintLocalTargetsGuard (test/Memex.Portal.Shared.Test) enforces the source end across memex/Memex.Portal.Shared and src/. For each minting site it resolves the interpolated value to its assignment in the same file:

It also asserts it found at least one minting site. A guard whose subject is renamed or moved out from under it otherwise passes having checked nothing, and that failure is indistinguishable from success.

🚨 A false positive here is worth reading, not suppressing. On its first run the guard reported a site in each connect endpoint — both of which DOCUMENT their route as GET /connect/github?returnPath={path} in an XML comment, where the brace is a placeholder for a human reader and names no value at all. The fail-closed branch was right to report something it could not resolve; the fix was to make prose not a subject, not to loosen the classification.

The second occurrence: the Blazor sign-in redirect

The same defect shipped again in the GUI, and the guard could not see it (MeshWeaver.Plugins#4448). A signed-out visitor who opens a page they may not read is sent to sign in by RedirectToLogin or by NavigationService's anonymous gate, and both minted the target as Uri.EscapeDataString(NavigationManager.Uri). A NavigationManager's Uri is absolute by contract, yet it spells neither :// nor Request.Scheme, so the classification above read it as local. The login page dropped it, and an invitee who followed an invite link to a Space signed in and landed on / instead of the Space.

On an instance with a public host that is worse than a lost target, and it is why the report read as "sent to the marketing site". On the app host, / is the landing page, and a SIGNED-OUT GET for a public page there answers a 301 to the public host. A browser stores a 301 with no cache directive and replays it without asking the server, so after signing in the visitor's / went to the public host and the session cookie stayed on the app host. Two fixes, one per defect:

Source references

File Purpose
memex/Memex.Portal.Shared/Authentication/ReturnUrlPolicy.cs The one rule for the portal's server surface
memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs /authorize — mints the login return target
memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs The same mint, done right, with the reason written down
test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs The sink end: every consumer routes through the one policy
test/Memex.Portal.Shared.Test/RedirectSourcesMintLocalTargetsGuard.cs The source end: every minted target is one the policy keeps
test/Memex.Portal.Shared.Test/OAuthAuthorizeReturnUrlIsLocalTest.cs /authorize's own target, with a control on each side of the change