Repository navigation
Ten review findings on Memex.Portal.Shared surfaced by the GUI extraction — open redirects, an uncapped anonymous read, HMAC-after-buffer #2302
Description
Automated review of MeshWeaver.Plugins#674 (the portal move) surfaced ten findings on files that the
split then moved back to core. The PR's paths are stale; the findings are not — every one
describes code live in memex/Memex.Portal.Shared (and one in MeshWeaver.Blazor/Infrastructure)
on core main today.
Filing them here because resolving the stale threads on that PR would otherwise discard them
silently, and several are security-relevant.
🚨 Not introduced by the extraction. #674 is a move; these files' contents are pre-existing. The
review simply made them visible by touching the files. That is also why they should not be fixed
inside a move PR — it would conflate a relocation with behavioural change.
---### src/Memex.Portal.Shared/Authentication/ExternalAuthController.cs
returnUrl is attacker-controlled and is passed directly to Redirect, so /auth/login?...&returnUrl=https://evil.example turns the successful sign-in callback into an open redirect. Restrict the destination to a local URL at the redirect sink (the GitHub and onboarding flows already do this).
This issue also appears on line 96 of the same file.
src/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs
The comment promises a capped reader for chunked bodies, but ReadToEndAsync has no cap. Because this endpoint is anonymous, a request without Content-Length can be fully buffered before WebhookInbox.Deliver returns TooLarge, allowing concurrent deliveries to consume memory well beyond MaxBodyBytes. Enforce the byte limit while reading and return 413 as soon as it is crossed.
src/Memex.Portal.Shared/Social/GitHubWebhookEndpoints.cs
The HMAC is checked only after the unauthenticated request body has been copied into a MemoryStream and then duplicated by ToArray(), with no endpoint-specific size limit. Invalid callers can force large allocations before authentication. Apply a tight streaming/request-body cap before buffering and reject oversized payloads with 413.
src/Memex.Portal.Shared/Authentication/McpBackConnectionService.cs
A revoked cached token can never be replaced: every later spawn returns it from this branch, and neither CLI client reports a subsequent /mcp 401 back to this service. Contrary to the class comment, there is no auth-on-exception path that evicts the entry, so mesh access remains broken until the portal process restarts. Add an invalidation/retry contract or validate and evict cached tokens before reuse.
src/MeshWeaver.Blazor/Infrastructure/UserIdentityCache.cs
This subscribe-before-read change fixes a timing-sensitive hang, but no regression test for UntilDetermined was moved into this repository. Add a deterministic hot-observable test that emits between subscription and the initial lookup and proves the determined value is delivered; also cover a throwing initial lookup disposing the hot-feed subscription. Without those cases, a future operator rewrite can reintroduce the 60-second timeout while all builds remain green.
src/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs
CheckedAt is a stored instant, but formatting it directly displays the container's UTC value rather than the admin's local time. Capture the viewer zone once in BuildContent, pass it into this pure formatter, and convert with DisplayTimeExtensions.ToDisplayTime before formatting.
This issue also appears in the following locations of the same file:
- line 183
- line 191
src/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs
Created, expiry, and last-used values are stored instants, but all three are formatted directly in UTC. Convert each through the viewer's zone (captured once from AccessService) before formatting; even the date-only fields can otherwise be off by one day for the viewer.
src/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs
This auth-path query uses scope:subtree without a namespace/path anchor, so every role lookup unions AccessAssignment rows across every partition and gets slower as tenants are added. The limit:10 also makes users with more grants lose roles nondeterministically. Resolve roles from an indexed, user-anchored source (or maintain a dedicated access-object index) rather than scanning the whole mesh.
src/Memex.Portal.Shared/Social/GitHubLoginEndpoints.cs
This local-URL check rejects //host but accepts /\\host. Browsers normalize backslashes in special-scheme URLs, so a crafted return target can become a protocol-relative external redirect. Reject the /\\ form as the onboarding guard already does.
src/Memex.Portal.Shared/Authentication/DevAuthController.cs
Dev sign-in redirects to an unvalidated form value. Self-hosted/distributed deployments can explicitly enable dev login, so this is still an open redirect rather than a localhost-only assumption. Validate a same-site relative path before redirecting.
This issue also appears on line 233 of the same file.
Suggested triage order
- Open redirects (3) —
ExternalAuthController,Login.razor,DevAuthController. All take an
attacker-controlledreturnUrlinto a redirect.DevAuthControllermatters because self-hosted
and distributed deployments can explicitly enable dev login. GitHubLoginEndpoints— the local-URL check rejects//hostbut accepts/\host, and
browsers normalise backslashes in special-scheme URLs. Same class, subtler.WebhookInboxEndpoints— the comment promises a capped reader;ReadToEndAsynchas no cap,
on an anonymous endpoint. A request withoutContent-Lengthis unbounded.GitHubWebhookEndpoints— HMAC verified only after the unauthenticated body is copied into
aMemoryStreamand duplicated byToArray().OnboardingMiddleware—scope:subtreewith no namespace anchor on the AUTH path, unioning
AccessAssignmentrows across every partition. This repo has been bitten by unanchored
cross-partition queries before.McpBackConnectionService— a revoked cached token can never be replaced; every later spawn
returns it and no/mcp401 invalidates it.- UTC formatting (2) —
UpdatePolicySettingsTabandApiTokensSettingsTabformat stored
instants directly, rendering the CONTAINER's clock rather than the viewer's. AGENTS.md's
localization rule covers formatting, not only translated strings. UserIdentityCache— no regression test forUntilDeterminedaccompanied the
subscribe-before-read fix (Arm the observer BEFORE the trigger — a hot emission missed has no second chance #2259).
I have not verified any of these independently — they are review output, reproduced faithfully and
relocated to the repo that owns the code. Each needs confirming before it is acted on.
Activity
rbuergi commentedon Aug 26, 2026
Two of the ten fixed in #2374 — the open redirect (
ExternalAuthController:89/:100) and the unbounded webhook body read (WebhookInboxEndpoints:60). Both verified live on main before fixing; both are in the platform half that stays in core. Note the body-read one was worse than filed: the comment beside it described a capped reader that did not exist. The remaining eight stay open here.rbuergi commentedon Aug 28, 2026
Working through this list. Status of the five findings I have now checked against
main@b010a3ba2:ExternalAuthController(×2)ReturnUrlPolicy.SanitizeWebhookInboxEndpointsBoundedBody.ReadAsync+ 413GitHubWebhookEndpoints/\hostbypass —GitHubLoginEndpoints.SafeLocalDevAuthController(×2)A detail worth recording from #2573: the assembly held four implementations of the local-URL rule —
ReturnUrlPolicy.Sanitize(correct),EaConsentController.SafeReturnUrl(correct),GitHubLoginEndpoints.SafeLocal(wrong),DevAuthController(absent). Testing the policy harder would have found none of it, which is why that PR adds a ratchet on one implementation, every sink uses it rather than more policy tests.OnboardingMiddleware— verified live, and I am NOT fixing itBoth halves of the finding are exactly as reported. The
limit:10is the sharper one: it is role loss, not just slowness. A user with more than tenAccessAssignmentgrants gets an arbitrary ten, so their effective roles can differ between requests — on the auth path.How live is it? I could not count grants per user:
contentcomes back as a shell projection in query results, socontent.accessObjectis not readable from a search. What I can measure is the shape — on memex-cloud the assignment set is truncated at a 200-row query, spread across many space containers (AppleMaps/_Access31,AgenticEngineering/_Access24, …). One assignment per space per user is the normal pattern, so a user in more than ten spaces crosses the cap, and this mesh plainly has more than ten spaces. Structurally reachable; I have not identified a specific affected user, and I am not claiming one.Why I stopped rather than patched. The obvious edit — raise or drop
limit:10— trades silent role loss for an unanchoredscope:subtreescan across every partition on every authenticated request, which is the other half of the same finding. And anchoring the query is not a one-liner either: the Admin partition is invisible to unscopednodeType:queries (a trap that has already blinded the self-updater), so a naive anchor can silently return fewer roles rather than more — the same failure it is meant to fix, harder to see.So this one wants the "indexed, user-anchored source" the finding proposes, chosen deliberately. It is the highest-severity item left on this list and should probably be split into its own issue rather than sitting fifth in a ten-item review dump.
Remaining unchecked here:
UpdatePolicySettingsTab,ApiTokensSettingsTab(UTC formatting vs viewer zone), and the others below them.Review: a FIFTH copy, and a guard that certified it because of its name
rbuergi commentedon Aug 28, 2026
All ten findings now checked. Completing the triage so the next pass starts from measurements rather than re-deriving them.
mainExternalAuthController(×2)ReturnUrlPolicy.Sanitize)WebhookInboxEndpointsBoundedBody+ 413)GitHubWebhookEndpoints/\hostaccepted —GitHubLoginEndpoints.SafeLocalDevAuthController(×2)ApiTokensSettingsTab(×3)UpdatePolicySettingsTab(×3)scope:subtree limit:10—OnboardingMiddlewareMcpBackConnectionServiceUntilDeterminedregression test —UserIdentityCacheA sixth open-redirect copy turned up while fixing #4/#5 and is in PR #2573 as well:
GitHubConnectEndpoints.SafeReturnchecked only for a leading/, so it accepted//evil.exampleoutright — weaker than theSafeLocalbug this issue names, and it was missed because its sink is calledreturnPathrather thanreturnUrl.9 —
McpBackConnectionService: confirmed, no eviction path existsThe only match is that comment. There is no code that removes or revalidates a cached entry, so the class comment describes a contract the class does not implement — exactly as reported. A revoked token stays cached until the process restarts.
10 —
UserIdentityCache: confirmed, still untestedOne hit, and it is the implementation. No test anywhere references
UntilDetermined. Note the path in this issue is stale — the file now lives underMeshWeaver.Hosting.AspNetCore/Portal, notMeshWeaver.Blazor/Infrastructure— but the gap is real and the finding's warning holds: a future operator rewrite can reintroduce the 60-second hang with every build green, because nothing exercises the emit-between-subscribe-and-lookup window.Neither 9 nor 10 is fixed here. 9 needs an invalidation contract decided (validate-before-reuse vs evict-on-401, and which component reports the 401); 10 is a contained piece of work — a deterministic hot-observable test plus a throwing-initial-lookup case — and is the cheapest remaining item on this list.
Merge pull request #2581 from Systemorph/fix/2302-untildetermined-test
rbuergi commentedon Aug 28, 2026
Status after #2581 (merged) and #2583 (in CI):
UserIdentityCache— noUntilDeterminedregression testLoadUserRoleslimit:10truncationfound 10)OnboardingMiddleware— unanchoredscope:subtreeon the auth pathMcpBackConnectionService— revoked cached token can never be evictedFinding 5 is real, and it is the opposite of what it looks like
Working on #2583 I read this query closely. The finding says an unanchored
scope:subtree"unionsAccessAssignmentrows across every partition", implying the fix is to anchor it. Anchoring it would be wrong: a user legitimately holds grants in many partitions, and the query is already narrowed bycontent.accessObject, so a namespace anchor would silently drop grants — the same failure mode as the truncation, arriving by a different route.The actual defect is the reverse. A pathless cross-schema query deliberately excludes the admin schema — this repo states it in its own comment in
Onboarding.razor:So
LoadUserRolescannot see grants inAdmin/_Access— which is exactly where platform-admin grants live (AGENTS.md: "the grant lives inAdmin/_Access, never root_Access"). A platform admin's Admin role is therefore missing from theAccessContext.Rolesthis read stamps.Why I did not fold it into #2583. Its blast radius is different from the other two fixes: those strictly widen a read toward the truth, whereas changing scope semantics can remove rows if I get the shape wrong, and the correct shape is a union (global for ordinary grants plus a
path:Admin/_Access scope:childrenleg, the shape #743 established forfirstUserCheck). That deserves its own change and its own red-proof, not a rider on a PR that is already green.It may also be latent rather than live, which is the other reason not to rush it: AGENTS.md is explicit that admin gates go through
hub.IsGlobalAdmin(), andPermissionEvaluatorreads Admin scopes with a path-scoped query of its own — so the authoritative fold is not blind here even though this stamped role list is. Worth measuring which consumers actually readAccessContext.Rolesfor an admin decision before deciding the severity.I have verified the query shape and the admin-schema exclusion; I have not demonstrated a user losing an admin capability because of it.
rbuergi commentedon Aug 28, 2026
Correcting my own earlier comment on finding 5 — I was wrong, and acting on it would have produced a fix for a problem that does not exist.
Earlier I wrote that the finding's suggested anchoring was backwards, and that "the real defect is the mirror image: a pathless cross-schema query excludes the admin schema, so
LoadUserRolescannot see grants inAdmin/_Access". I inferred that from a different observation (an unscopednodeType:BuildCompletionreturning 0 whilenamespace:Admin scope:descendantsreturned 7) and did not test it against the query this code actually issues.I have now run that exact shape against the live mesh:
Admin/_Access/rbuergi_Accessis the FIRST result. The Admin grant is returned. The exclusion I asserted does not apply to this query, so the consequence I drew from it — a platform admin's Admin role missing fromAccessContext.Roles— does not follow.I do not yet know why this shape reaches the admin schema when the
BuildCompletionone did not; the difference is likelyscope:subtreeor thecontent.predicate changing how the query is routed. That is worth understanding on its own, but it is not this finding.What finding 5 actually is, then
Back to what it says rather than what I turned it into: an unanchored
scope:subtreeon the auth path unionsAccessAssignmentrows across every partition schema, on every sign-in. That is a scale concern and it is real — see Systemorph/MeshWeaver.Plugins#983, which measures the same union shape across ~184 schemas — but it is a cost problem, not a correctness one, and it should be fixed with that framing.Which also means anchoring it by namespace, as the finding suggests, is still wrong for the reason I gave originally and stand by: a user legitimately holds grants in many partitions, so a namespace anchor would silently drop them. The fix has to preserve the union while making it cheaper — pinning by the accessObject index rather than by partition.
Apologies for the noise. Flagging it explicitly because a confident wrong analysis in an issue thread is worse than none: the next person would have implemented an Admin-partition union leg that fixes nothing.
Merge pull request #2607 from Systemorph/fix/2302-mcp-token-eviction
rbuergi commentedon Aug 28, 2026
Walked all ten findings against
main@aeecb0d3b— every one is resolvedChecked each finding against live code rather than against the PRs that claimed them, since the point of this issue was that filed findings become an unwalked to-do list.
ExternalAuthControlleropen redirect:89,:100) areRedirect(ReturnUrlPolicy.Sanitize(returnUrl))WebhookInboxEndpointsuncappedReadToEndAsyncBoundedBody.ReadAsync(request.Body, WebhookInbox.MaxBodyBytes, ct), plus aContentLengthpre-check and 413 on bothGitHubWebhookEndpointsHMAC-after-buffer:77precedesVerifySignatureat:81— the cap is applied before bufferingMcpBackConnectionServicerevoked token never replacedInvalid)UserIdentityCacheUntilDeterminedhas no regression testtest/MeshWeaver.Hosting.Monolith.Test/UntilDeterminedTest.cs— and it covers precisely the two cases asked for:An_emission_during_the_initial_lookup_is_still_deliveredandA_throwing_initial_lookup_disposes_the_feed_subscriptionUpdatePolicySettingsTabformats UTCDisplayTimeExtensions.ToDisplayTime(…, zoneId)ApiTokensSettingsTabformats UTC:194viaViewerZoneId(); created/expiry/last-used all convertedOnboardingMiddlewareunanchoredscope:subtree+limit:10SecurityQueries.Enumeration(non-truncatable,limit:all) run underRunAsSystem, withIdentityReadsRunAsSystemGuardas the ratchetGitHubLoginEndpointsaccepts/\\hostSafeLocalis now=> ReturnUrlPolicy.Sanitize(returnUrl)— one policy, not a second implementationDevAuthControllerunvalidated redirect:179,:243) sanitizedThe three open redirects and the
/\\hostbypass converged on one policy.ReturnUrlPolicy.Sanitizeis now the single sink guard, andReturnUrlPolicyTestpins the whole class in one place — including the two forms this issue called out separately,//evil.exampleand/\\evil.example, alongsidejavascript:, absolute URLs and the bare-host form. That is the durable shape: the finding was that two hand-written local-URL checks disagreed, so having one is what stops it recurring.One I checked and was wrong about, recording it so nobody re-checks:
EaConsentController:97/:102look like rawRedirect(returnUrl)under grep, butreturnUrlthere is already the result ofSafeReturnUrl(...)at:90— and it is deliberately re-sanitised on the callback because the value round-trips through the IdP asstate. Not a finding.Closing. No code change was needed for this walk — the work landed across #2583, #2607 and the
ReturnUrlPolicy/BoundedBodyconsolidations.