Skip to content

One local-URL policy, and a ratchet so a fifth copy cannot appear - #2573

Merged
rbuergi merged 2 commits into
mainfrom
fix/2302-open-redirect-sinks
Aug 28, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/2302-open-redirect-sinks

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Two more #2302 findings, both verified live on main before touching anything.

sink state
GitHubLoginEndpoints.SafeLocal rejected //host, accepted /\host
DevAuthController Redirect(returnUrl ?? "/") at two sites, no validation at all

Browsers normalise the backslash in special-scheme URLs, so /\evil.com still became a protocol-relative external redirect. And DevAuthController is gated on EnableDevLogin — which self-hosted and distributed deployments can enable, with the route registered regardless.

The count is the actual finding

This assembly held four implementations of one rule:

ReturnUrlPolicy.Sanitize correct — and already covered by a theory pinning //evil, /\evil, absolute URLs and javascript:
EaConsentController.SafeReturnUrl correct
GitHubLoginEndpoints.SafeLocal wrong
DevAuthController absent

Testing the policy harder would have found none of this. The policy was never the problem. So both wrong sinks now delegate — and so does the correct duplicate, because being right was not the issue: correct duplicates are what make an incorrect one look normal.

RedirectSinksUseOnePolicyGuard pins both halves — no redirect may take a request-supplied target without the shared policy, and no second implementation of the protocol-relative check may exist.

The guard needed fixing before it was worth having

🚨 My first version matched only the inline argument, and reported all four EaConsentController sites as open redirects. Every one is correct — the value is sanitised into a local first (one of them reassigns returnUrl itself). It now scans the enclosing region. Shipping the strict version would have taught the next reader to suppress the guard, which is worse than not having it.

Verification — including one attempt that was itself wrong

My first red-proof edited the sources with a bad escape. The rebuild failed, --no-build then ran the previous binary, and the guard reported "passing" over the defect. A textbook false pass, caught only because the build line said 0 where it should have said Build succeeded.

Redone properly — revert via git checkout origin/main --, confirm the build, then run:

DevAuthController reverted  → guard FAILS, naming both sites
restored                    → 382 / 382 pass, -warnaserror clean

What's New

Skipped — no user-visible effect for any legitimate redirect target.

#2302: two open-redirect findings, both verified live on main first.

  GitHubLoginEndpoints.SafeLocal  rejected "//host" but ACCEPTED "/\host".
  Browsers normalise the backslash in special-scheme URLs, so a crafted target
  still became a protocol-relative external redirect.

  DevAuthController              `Redirect(returnUrl ?? "/")` at TWO sites,
  with no validation whatsoever. Gated on EnableDevLogin, which self-hosted and
  distributed deployments can turn on — and the route is registered regardless.

The interesting part is the count. This assembly held FOUR implementations of
one rule:

  ReturnUrlPolicy.Sanitize             correct, and already covered by a theory
                                       pinning //evil, /\evil, absolute and
                                       javascript: targets
  EaConsentController.SafeReturnUrl    correct
  GitHubLoginEndpoints.SafeLocal       WRONG
  DevAuthController                    absent

So testing the policy harder would have found none of it. Both wrong sinks now
delegate — and so does the correct duplicate, because being right was never the
issue: correct duplicates are what make an incorrect one look normal.

RedirectSinksUseOnePolicyGuard pins both halves: no redirect may take a
request-supplied target without the shared policy, and no second implementation
of the protocol-relative check may exist.

🚨 The guard scans the ENCLOSING REGION, not the argument. My first version
matched only the inline expression and reported all four EaConsentController
sites as open redirects — every one of them correct, sanitised into a local
first. Shipping that would have taught the next reader to suppress it.

Verified, and one attempt at verification was itself wrong: my first red-proof
edited the sources with a bad escape, the rebuild FAILED, and `--no-build` then
ran the previous binary and reported the guard "passing" over the defect. Redone
by reverting DevAuthController with `git checkout origin/main --`, confirming
`Build succeeded`, and only then running:

  reverted   -> guard FAILS, naming both DevAuthController sites
  restored   -> 382/382 pass, -warnaserror clean

Refs #2302.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens redirect handling in Memex.Portal.Shared by consolidating request-supplied redirect targets behind ReturnUrlPolicy.Sanitize, and adds a test “ratchet” intended to prevent new ad-hoc local-URL checks at redirect sinks.

Changes:

  • Delegate GitHubLoginEndpoints.SafeLocal and EaConsentController.SafeReturnUrl to ReturnUrlPolicy.Sanitize.
  • Sanitize returnUrl at both redirect sites in DevAuthController.
  • Add RedirectSinksUseOnePolicyGuard to enforce consistent use of the shared policy at redirect sinks.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs Adds guard tests to ratchet redirect sinks toward ReturnUrlPolicy.Sanitize and discourage duplicate local-URL checks.
memex/Memex.Portal.Shared/Social/GitHubLoginEndpoints.cs Replaces hand-rolled SafeLocal with delegation to ReturnUrlPolicy.Sanitize.
memex/Memex.Portal.Shared/Authentication/EaConsentController.cs Replaces SafeReturnUrl implementation with delegation to ReturnUrlPolicy.Sanitize.
memex/Memex.Portal.Shared/Authentication/DevAuthController.cs Sanitizes returnUrl before redirecting on login and logout.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs Outdated
Comment thread memex/Memex.Portal.Shared/Authentication/EaConsentController.cs
Comment thread test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs Outdated
Three review findings, all correct, and the first is a live open redirect the
original pass of #2302 missed.

1. GitHubConnectEndpoints.SafeReturn tested only for a leading "/", so
   "//evil.example" and "/\evil.example" passed straight through to Redirect.
   That is weaker than the SafeLocal bug this PR already fixes — that one at
   least rejected "//". Now delegates.

   So the assembly had FIVE copies of one rule, not four, and THREE were wrong.
   Counts in the surrounding comments corrected.

2. EaConsentController's doc had two consecutive <summary> blocks: my edit added
   one and left the original, which still described a hand-written IsLocalUrl
   "spelled out here" that no longer exists. Stale block removed.

3. The ratchet was narrower than its own name claimed, twice:
   * it recognised only returnUrl/returnTo, so the returnPath sink — the live
     bug above — was never ratcheted at all;
   * it scanned for StartsWith("//"), while ReturnUrlPolicy itself uses index
     comparison (u[1] == '/'), the exact form EaConsentController's copy used.

🚨 And fixing those exposed a worse version of the same mistake, which is the
part worth reading. My widened guard allowed any call named Safe* to count as
sanitisation — so when I red-proofed it against the restored GitHubConnect bug,
it PASSED. A helper called SafeReturn that accepts "//evil.example" was
certified by the guard because of what it was called.

The invariant is not "a sanitiser is called" but "no one re-implements the
rule". The second test now finds every Safe…/Sanitize… helper in the local-URL
naming family outside ReturnUrlPolicy and requires its body to call
ReturnUrlPolicy.Sanitize. Delegation is checkable; the correctness of a
hand-rolled copy is not.

Narrowed to that naming family after it flagged UpdatePolicySettingsTab's
markdown Sanitize — a false positive is how a guard gets suppressed rather than
fixed.

Verified: with GitHubConnectEndpoints reverted the delegation test FAILS naming
exactly SafeReturn and nothing else; restored, 382/382 pass, -warnaserror clean.

Refs #2302.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi enabled auto-merge August 28, 2026 07:53
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 610 tests   1 609 ✅  9m 8s ⏱️
    9 suites      1 💤
    9 files        0 ❌

Results for commit 842cd52.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 088 tests   896 ✅  5m 24s ⏱️
    6 suites  192 💤
    6 files      0 ❌

Results for commit 842cd52.

♻️ This comment has been updated with latest results.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 317 tests   1 317 ✅  6m 5s ⏱️
    6 suites      0 💤
    6 files        0 ❌

Results for commit 842cd52.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 126 tests   1 126 ✅  18m 10s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit 842cd52.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    8 files      8 suites   11m 59s ⏱️
4 431 tests 4 428 ✅ 3 💤 0 ❌
5 038 runs  5 035 ✅ 3 💤 0 ❌

Results for commit 842cd52.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

739 tests   735 ✅  7m 12s ⏱️
  8 suites    4 💤
  8 files      0 ❌

Results for commit 842cd52.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    44 files      44 suites   57m 59s ⏱️
10 309 tests 10 109 ✅ 200 💤 0 ❌
10 918 runs  10 718 ✅ 200 💤 0 ❌

Results for commit 842cd52.

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi merged commit 7e0c221 into main Aug 28, 2026
34 of 36 checks passed
@rbuergi
rbuergi deleted the fix/2302-open-redirect-sinks branch August 28, 2026 08:56
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