Skip to content

Paywall bypass: MCP exact-path get returns gated paid content #822

Closed

Description

@rbuergi

Impact

A signed-in user with no entitlement can read gated paid course content via MCP get by exact path on memex.

Repro (any account without an entitlement):

get @AgenticPrimerDe/02-CodeWunsch      → returns 79,650 chars of the paid lesson
get @AgenticPrimerDe                    → cover, correctly readable

02-CodeWunsch carries an explicit deny: Public|AgenticPrimerDe/02-CodeWunsch|Read|is_allow=false.

Ruled out (all measured on production 2026-08-05)

Hypothesis Evidence against
Gating data wrong denies present and correct; SQL query path hides the same node
Permission projection empty per-partition schemas populated (sst = 46 rows). admin.user_effective_permissions is the WRONG table — it's per-partition
Missing node_type index all 136 schemas have it
Database slow / at fault 136-schema union answers in 57 ms
Identity lost in MeshOperations.FetchNode fixed in #819, deployed 3.0.0-ci.1829 → still leaked
Identity absent at the MCP tool call fixed in #821, deployed 3.0.0-ci.1832 → still leaked
UserContextMiddleware not covering /mcp ordering is correct (UseMiddleware<UserContextMiddleware> before MapMeshMcp), with a comment documenting this exact bug being fixed previously

Key evidence

1. The read reaches the grain as System:

GrainCallFilter: grain=messagehub/AgenticPrimerDe/02-CodeWunsch,
                 method=DeliverMessage, userId=system-security

system-security holds Permission.All, so MeshNodeStreamCache.GetStreamRaw's gate passes legitimately. That gate deliberately falls through to its shared upstream — opened under the cache's own system read identity — when no user identity is ambient. Absent identity does not fail closed.

2. MeshOperations.Get's entry log never appeared during a probe with Logging__LogLevel__MeshWeaver.AI=Debug, one portal pod, /mcp confirmed routing to it. Caveat: the grep was tail=3000 shortly after a restart, so startup volume may have pushed it out — this needs re-testing on a warm pod before being treated as fact.

Suggested next steps

  1. On a warm pod with MeshWeaver.AI=Debug, probe and confirm whether "Get called with path" appears. That settles whether the repo's MeshOperations.Get is the executing code.
  2. If it appears: the AccessContext is null at tool-invocation time despite correct middleware ordering → the MCP SDK likely dispatches tool calls on a fresh async context. Fix by flowing identity from IHttpContextAccessor/the MCP session rather than the AsyncLocal.
  3. Consider making the cache gate fail closed for partition-rooted user paths when no identity is ambient. This is the durable fix but has mesh-wide blast radius (the fall-through exists for background/hub-principal flows) and needs its own test pass.

Related

🤖 Generated with Claude Code

Activity

  1. rbuergi commented on Aug 5, 2026

    @rbuergi
    ContributorAuthor

    Still open after #823 — but the search-vs-get measurement finally isolates it

    #823 is merged and live (3.0.0-ci.1835). The bypass reproduces unchanged, and a node I had never fetched (03-MehrMagie) came back in full — so this was never a cached response either.

    The decisive measurement — same MCP connection, same credential, same second

    search path:AgenticPrimerDe scope:subtree nodeType:Edu/Module
      → ONLY 01-Zauberwunsch (the free lesson, no deny row). 02 and 03 correctly HIDDEN.
    
    get @AgenticPrimerDe/03-MehrMagie
      → FULL lesson content.
    

    What that proves, and what it kills

    Conclusion Why it follows
    The caller is not System System would see all three in search; it sees one
    Identity is present and correct the same credential is correctly gated by search
    The SQL/RLS path is correct it folds the Public deny and hides 02/03
    The hole is the exact-path hub read same identity, same instant, opposite answers
    It is not in MeshOperations three fixes there did nothing; a Debug probe logged zero "Get called with path" lines

    So the whole lost/absent identity family of hypotheses — which #819, #821 and #823 were built on — is dead. The per-node hub read simply never consults the partition RLS that GenerateAccessControlClause applies on the query path.

    Why the tests in #823 did not catch it

    PaywallIdentityMatrixTests.AuthenticatedButNotPurchased_IsDenied passed before any fix. The tests hand-seed their own gate rather than using the real AddPluginGating shape (_Policy publicRead:false + root Public/Anonymous Viewer + per-child Viewer DENIED (gated)), so the harness denies where production serves. Only the no-ambient-identity case ever failed — a genuine defect, correctly fixed by #823, but not this one.

    #823 is still worth keeping: no-identity reads previously ran as System (Permission.All).

    Next steps, in order

    1. Write the repro against the real AddPluginGating shape and confirm it fails before touching code.
    2. Fix the exact-path read (IMeshNodeStreamCache.GetStream / the per-node hub read) to apply the same partition RLS as the query path.
    3. ⚠️ An RLS change on the primary read path can lock out every legitimate user mesh-wide — it needs its own design, a full-suite pass and a staged rollout. Not a hot-patch.

    Do not ship a fourth fix without step 1 going red first.

  2. added 2 commits that reference this issue on Aug 5, 2026
    0a8b47b
    1af1aaa
  3. rbuergi commented on Aug 6, 2026

    @rbuergi
    ContributorAuthor

    Verified on production (3.0.0-ci.1842, unentitled MCP credential, post-rollout)

    Probe Result
    get @AgenticPrimerDe/02-CodeWunsch (gated paid — previously served 79,650 chars) ✅ denied
    get @AgenticPrimerDe/03-MehrMagie (previously served in full) ✅ denied
    get @SST/StandReData ✅ denied
    get @AgenticPrimerDe (cover) ✅ renders
    get @AgenticPrimerDe/01-Zauberwunsch (free lesson) ✅ renders
    search ✅ unchanged — exactly the one free lesson

    search and get agree for the first time. Operator note: accounts whose reads worked only via platform DB roles (no assignment nodes) are now denied — intended; real access = entitlement or grant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions