fix(plugins): enforce the per-user plugin access gate - #589
Conversation
get_current_user is a FastAPI dependency, so it only resolves once routing has picked a handler. Code that must identify a caller earlier had no way to reuse it and would have to decode tokens and query users of its own, leaving two implementations of "who is this request from" free to drift apart — with the copy outside the dependency being the one that guards access. Split the two steps it composes into decode_token_username and get_user_by_username so any caller can reuse them, and rebuild the dependency on top. Behaviour is unchanged: both 401 responses keep their distinct detail messages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PluginAccessMiddleware never blocked anything: a user could call the endpoints of a plugin they had disabled, and a plugin disabled system-wide stayed reachable over HTTP. The gate was inert at three independent points, each one silently passing the request on. Since FastAPI 0.140 include_router() no longer copies sub-routes into the parent's routes; it appends a single lazy _IncludedRouter branch with no .endpoint. That broke both halves of the plugin-name stamp: register_router's loop found nothing to stamp, and the middleware's lookup matched the branch object rather than the route. Both now flatten the branches with iter_route_contexts, matching on the context (whose path carries the include prefix) and reading the stamp off original_route. The third point never worked at all: dispatch read request.state.user, which nothing in the codebase writes. Authentication is a dependency, so it resolves after all middleware has run. The gate now identifies the caller from the request's bearer token using the helpers get_current_user is built from. Requests with no readable token still pass — plugin routers carry unauthenticated endpoints such as the Slack OAuth callback, and a per-user preference is meaningless without a user. Also drop the "/" entry from the default exclude_paths: entries are matched with startswith, so it excluded every path and left a middleware built without explicit paths enforcing nothing. Production passes its own list, so no behaviour changes there. Closes #586 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate resolving one plugin proves nothing about the rest, and a plugin it cannot name is a plugin it cannot police — the same silent failure this branch fixes. Slack and Google Drive nest their route includes deeper than chat, so cover all three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@claude-review |
|
Claude finished @abdulrafey1's task in 3m 11s —— View job Claude Code Review
This is a well-executed, well-documented security fix. The three-layer diagnosis is accurate, the refactor that lets the gate and A few things worth addressing before merge. 🔴 High — the
|
| except (DatabaseError, OperationalError) as e: | ||
| logger.error(f"Database error checking plugin access for user {user_id} and plugin '{plugin_name}': {e}") | ||
| logger.error(f"Database error checking plugin access for user '{username}' and plugin '{plugin_name}': {e}") | ||
| return False |
There was a problem hiding this comment.
Medium — this fail-closed branch is the most security-critical path in the gate and has no test. On a DB error the gate returns False, i.e. it blocks the request (403). The new suite covers blocking-by-preference, blocking-system-wide, and letting-through, but not this "the lookup itself failed, so deny" behaviour, nor the sibling fail-open branch above where the token names a user that no longer exists (user is None -> return True). Given the repo's TDD rule and that error paths are the ones most likely to silently regress, both branches are worth a test — e.g. patch session_scope/get_user_by_username to raise OperationalError and assert 403, and seed a token whose sub matches no user and assert the request isn't 403 from the gate.
Note the docstring/PR wording: a DB error is documented as "failing open" but the code correctly fails closed here — worth aligning the wording so the intent is unambiguous.
There was a problem hiding this comment.
Both branches now have a test, and both were verified to fail when the branch they cover is inverted (flip return False → return True here and test_blocks_when_the_access_lookup_fails fails; flip the user is None branch and test_lets_through_a_token_naming_a_user_that_does_not_exist fails). The DB-error test patches get_user_by_username to raise OperationalError and asserts 403.
On the wording: the docstring already says the DB-error path blocks, not that it fails open —
A database failure blocks — the gate cannot confirm access, so it must not grant it.
The PR body's "failing open" refers to the anonymous path in the test-coverage bullet, not to database errors. Neither describes a DB error as failing open, so there's nothing to align here.
Constants belong in constants.py, where PLUGIN_NAME_PATTERN already lives, rather than sitting at the top of the module that happens to use them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The declared floor was >=0.121.2, but the plugin access gate now imports iter_route_contexts, which does not exist before 0.138 — any environment resolving a permitted version below it fails at import, taking the app down rather than just the gate. The lockfile happens to pin 0.140.0, so CI never saw it. Measured against real installs rather than assumed: include_router stopped copying sub-routes in 0.137 (that is where the gate broke, and 0.137 offers no iter_route_contexts to fix it with), and 0.138 is the first version exposing the helper, verified to flatten, match on the prefixed path, and expose original_route.endpoint exactly as 0.140 does. The comments claiming 0.140 are corrected to 0.137 for the same reason. Also cover the two branches of the gate that decide what happens when the lookup cannot answer: a token naming a user that no longer exists passes, and a database failure blocks. Both were untested, and the second is the most security-relevant path in the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addressed — with a correction on the version range🔴 fastapi floor — real problem, wrong numbersThe finding holds:
Two consequences worth noting beyond the pin:
🟡 Untested error branches — fixed
The wording nit is answered in the inline thread: the docstring says the DB path blocks, and the PR body's "failing open" refers to the anonymous path. 🟢 System-wide disable on anonymous endpoints — documentedCorrect, and worth stating outright, so 🟢 Route scan — agreed, the note was impreciseThe full scan on every non-excluded request is pre-existing and unchanged; only the token/DB cost is new. Fair point that "non-plugin requests are unaffected" reads as broader than it is. 🟢 Route-dependency refactorAgreed, and the argument is stronger than "eventually": this is now the second FastAPI routing-internals change to break this code, and 0.137 shows the failure can arrive in a release with no escape hatch at all. It stays out of this PR so the security fix is reviewable on its own. This comment was written with the assistance of an LLM (Claude). |
| anyone's plugin settings, so authenticate them the way you would any other public endpoint | ||
| (a signed state parameter, a request signature) rather than relying on the gate. | ||
|
|
||
| That applies to the system-wide switch too: disabling a plugin turns its endpoints off for |
There was a problem hiding this comment.
This section spells out the gate's holes but leaves out the biggest one: a plugin's MCP tools are not gated at all. register_plugin_tools registers every plugin's tools on the shared FastMCP server, and the /ai/mcp mount is a Mount with no endpoint, so _get_route_plugin_name returns None and the gate always passes. An admin who disables a plugin system-wide, expecting it off, still has all of its tools callable over MCP. Worth stating here so nobody reads "disabled" as covering the plugin's whole surface.
There was a problem hiding this comment.
Confirmed and documented in aca30f6. /ai/mcp is not excluded and _get_route_plugin_name returns None for it, so the gate passes every MCP request through — and register_plugin_tools registers every tool unconditionally.
Worth splitting the gap in two, because the halves are not equally fixable: the MCP surface has no authenticated caller at all (FastMCP takes no auth provider, the mount has no dependency, handlers take no user id), so per-user enforcement has nothing to check against. System-wide enforcement needs no identity and is doable now — which is the half your admin example lands on.
The new paragraph says both, and points at the tool itself as the place to enforce rather than the plugin switch.
Fix tracked in #591 with a proposed approach: a call-time FastMCP middleware beside ToolCallAuditMiddleware, not registration-time filtering — tools register once at startup while enabled flips at runtime. Keeping it out of this PR so the HTTP fix stays reviewable on its own.
One thing that turned up while checking: check_user_plugin_access, get_user_enabled_plugins and get_user_disabled_plugins in middleware.py have no callers anywhere. They look like the tool-side gate that was never wired up. Noted in #591 as either its implementation or a deletion.
The guide described the gate's limits for HTTP callers but left the surface it does not reach at all unmentioned, so "disabled" read as covering the whole plugin. Tracked as #591. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hamza-56
left a comment
There was a problem hiding this comment.
A few small things, nothing blocking.
| "/redoc", | ||
| "/openapi.json", | ||
| "/", | ||
| "/api/v1/auth", |
There was a problem hiding this comment.
Plain startswith matches past the segment boundary, so /api/v1/auth would also exclude a hypothetical /api/v1/authors. Matching path == p or path.startswith(p + "/") keeps exclusions scoped to their own subtree.
| and would never match the request — while original_route is the real route whose | ||
| endpoint holds the stamp. | ||
| """ | ||
| for context in iter_route_contexts(request.app.routes): |
There was a problem hiding this comment.
This re-flattens and scans the whole route table on every request, and Starlette then repeats the same matching to dispatch. Routes are static after startup, so building the flattened list (or a path to plugin-name cache) once in __init__ would be cheap. Not blocking, just runs on every single request.
| return None | ||
|
|
||
| async def _check_plugin_access(self, user_id: int, plugin_name: str) -> bool: | ||
| async def _user_may_use_plugin(self, username: str, plugin_name: str) -> bool: |
There was a problem hiding this comment.
Heads up that an authenticated plugin request now costs 3 sequential queries here (user, plugin, user_plugin), and then get_current_user decodes the same token and fetches the same user again. Could collapse into one joined query and/or stash the user on request.state for the dependency to reuse. Fine to punt, just worth knowing.
Closes: Users can call endpoints of plugins they have disabled
What
PluginAccessMiddlewarenever blocked anything — a user could call the endpoints of a plugin they had disabled, and a plugin disabled system-wide stayed reachable over HTTP. This makes the gate enforce, and covers it with the behavioural tests it never had.The gate was inert at three independent points, not the one the issue describes. Each silently passed the request on, so the failure was invisible.
sparkth/core/routes/__init__.py:28register_router's stamping loop iteratedprefixed_router.routes, which since FastAPI 0.137 is a single lazy_IncludedRouterbranch with no.endpoint.PLUGIN_NAME_ATTRIBUTEwas never set on any endpoint — the loop body never ran.sparkth/core/plugins/middleware.py:67_IncludedRouterbranch, soget_route_plugin_namereturnedNone. This is the layer #586 describes.sparkth/core/plugins/middleware.py:40dispatchreadrequest.state.user, which nothing in the codebase writes — that line was the only mention ofrequest.stateinsparkth/. Auth is a dependency, so it resolves after all middleware has run;userwas alwaysNone.Layers 1 and 2 are the same FastAPI 0.137 change hitting two places:
include_router()no longer copies sub-routes into the parent'sroutes. Both now flatten those branches withiter_route_contexts.Layer 3 needed a decision rather than a repair, since the middleware cannot be handed a user that has not been resolved yet. It now identifies the caller from the request's bearer token, reusing the helpers
get_current_useris built from so the two paths cannot drift.Note
The snippet in the issue's proposed-fix comment does not work as written, as that comment anticipated:
context.original_route.matches(scope)returnsMatch.NONE, becauseoriginal_route.pathis the unprefixed/completions. Matching goes throughcontext.matches(scope)(whosepath_regexcarries the include prefix);original_routeis only used to read the stamp.Anonymous requests deliberately pass the gate. Plugin routers carry unauthenticated endpoints — Slack's
oauth_callbackis called by Slack itself, with no token — and a per-user preference is meaningless without a user. Endpoints that do require a caller are still rejected by their own auth dependency.Changes
fix(plugins): stamp the plugin name on the routesinclude_routeractually mounts, viaiter_route_contextsfix(plugins): resolve the route's plugin through the flattened route contexts instead of the_IncludedRouterbranchfix(plugins): identify the caller from the request's bearer token, since no middleware populatesrequest.state.userfix(plugins): drop the"/"entry from the defaultexclude_paths— entries are matched withstartswith, so it excluded every pathrefactor(auth): extractdecode_token_usernameandget_user_by_usernamefromget_current_user, so the gate and the dependency share one implementationrefactor(plugins): moveBEARER_SCHEMEintocore/plugins/constants.py, alongsidePLUGIN_NAME_PATTERNfix(deps): raise thefastapifloor to>=0.138.0, the first version exposingiter_route_contextstest(core): cover the gate's behaviour — blocking, letting through, failing open on anonymous, failing closed on a database error — plus the plugin-name stamp and the auth helpersdocs(plugins): document that plugin routes are gated, and that unauthenticated endpoints are notHow to Test
make mypy && make lint.backend && make lint.format.backend check=1— all clean.python -m pytest -q— 1680 passed, 3 skipped. No existing test changed behaviour.python -m pytest tests/core/plugins/test_middleware.py tests/core/test_routes.py tests/lib/test_auth.py -q— 26 passed, the new coverage.Confirm the tests fail without the fix:
git stashthesparkth/changes and re-run step 3.test_blocks_a_plugin_the_user_disabledreturns 422 instead of 403 — the request sailed past the gate and reached body validation — andtest_register_router_stamps_the_plugin_name_on_its_routesasserts[None] == ['stamp-test'].Run the reproduction from the issue against this branch. It prints
chatwhere it printedNone:End-to-end, against a running app: disable the chat plugin for a user, then
POST /api/v1/chat/completionswith that user's token. Expect403naming the plugin, and aWARNINGin the logs. Re-enable it and repeat — the request reaches the handler.Notes
403where they previously served the request. That is the bug being fixed, but anyone relying on the gate being inert will see requests start failing.Match.PARTIAL, notMatch.FULL, so it reaches the router and returns405rather than403. No plugin functionality is reachable that way; called out because it is visible.fastapi[standard]>=0.121.2→>=0.138.0.iter_route_contextsdoes not exist below 0.138, so the previously-permitted range wouldImportErrorat startup. Measured per version:include_routerstopped copying sub-routes in 0.137 (where the gate actually broke, with noiter_route_contextsto fix it), and 0.138–0.140 behave identically for everything this code relies on. TheSince FastAPI 0.140comments, including the pre-existing one intests/core/test_assemble_app.py, are corrected to 0.137._get_route_plugin_nameruns on every non-excluded request as it always has — unchanged by this PR; only the token/DB cost is new, and that falls solely on plugin requests./ai/mcpis a mount, so no plugin name resolves, and the MCP surface has no authenticated caller to check a preference against. Disabling a plugin does not stop its tools being called over MCP. Documented indocs/guides/plugins.mdand tracked in Plugin tools remain callable over MCP after the plugin is disabled #591; deliberately not fixed here, since the correct seam is a call-time FastMCP middleware rather than anything in this gate.dependencies=[...], asCLAUDE.local.mdprescribes for gatekeeping) than as middleware. It would run after routing, so it would need neither its own token decoding nor any coupling to FastAPI's routing internals — which is what broke here, in two places at once. That is an architecture change, and this security fix should be reviewed on its own.This description was written with the assistance of an LLM (Claude).