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:
- An MCP client sends the user to
/authorize?…. - The user is not signed in, so
/authorizeredirects to/login?returnUrl=<absolute url>. - The login page will not keep a non-local target, so the provider link carries no
returnUrl. - The user signs in with Microsoft — successfully — and lands on
/. - The
/authorizerequest is gone. No authorization code is ever issued. - 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:
- an assignment composing a scheme and host → refused, naming the file and the expression;
- an inbound parameter of the enclosing method → a forward, not a mint; that value's safety is the sink guard's subject where it lands;
- anything else → fails closed, because a site the guard cannot classify is not a clean one.
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:
- Mint through
ReturnTarget.LocalOf(NavigationManager.Uri)(MeshWeaver.Blazor), which keeps the path and query. The guard now scans the plugins repository's wholesrc/tree, Razor files included, because the Blazor shell had moved out of both of its old roots. It also classifies a raw…Navigation….Urias absolute. - The stranger's redirect is never stored (
Cache-Control: no-store,Vary: CookieonUsePublicHostRedirect). It stays a301for crawlers, but every browser asks again, so the decision is made on the request it is about. A redirect decided by WHO is asking must never be cacheable by a browser.
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 |