Summary
The ArcadeDB MCP (Model Context Protocol) HTTP transport is the only command transport that never binds the authenticated principal onto the request thread's DatabaseContext. The engine's authorization primitives are deliberately no-ops when no user is bound, so every permission gate silently passes for MCP callers. A non-root MCP-allowed user can perform arbitrary writes, DDL, schema and security mutations, and (via the js query sub-case) execute arbitrary in-JVM JavaScript.
Root Cause
- Every non-MCP transport binds the principal before executing (e.g.
DatabaseAbstractHandler.java:88-90 current.setCurrentUser(user.getDatabaseUser(database)); Bolt/Postgres/gRPC similarly).
LocalDatabase.checkPermissionsOnDatabase (LocalDatabase.java:715-730) and checkPermissionsOnFile (:733) return early (no-op) when security==null, dbContext==null, or getCurrentUser()==null — the mechanism embedded/HA-apply contexts use to skip checks.
- The MCP transport (
MCPHttpHandler extends AbstractServerHttpHandler, NOT DatabaseAbstractHandler) resolves the DB only via MCPToolUtils.resolveDatabase (coarse canAccessToDatabase) and NEVER calls setCurrentUser. grep setCurrentUser matches only gRPC/Bolt/HTTP-DatabaseAbstractHandler/Postgres.
- Worst sub-case:
QueryTool.execute checks only isAllowReads() (default true), then engine.analyze(query); for language=js, PolyglotQueryEngine.analyze() (PolyglotQueryEngine.java:196-206) eagerly eval()s the script BEFORE QueryTool's isIdempotent() check — so a read-only MCP user runs arbitrary JS.
Impact
A non-root MCP-allowed reader performs arbitrary writes / schema / security mutation / in-JVM JS execution. Re-opens GHSA-48qw, GHSA-vwjc, GHSA-8vr5, and CVE-2026-54077 for MCP callers. C:H/I:H/A:H.
Attack Chain (query+js sub-case — needs only default allowReads)
- Entry:
POST /api/v1/mcp Basic-auth as a non-root MCP-allowed reader → tools/call name=query {database, language:"js", query:"database.command('sql','UPDATE User SET role=...'); 1"}. Guard: MCPDispatcher.dispatch auth + isUserAllowed; QueryTool checks isAllowReads() (default true). Bypass proof: reader is allowed; allowReads default true.
- Check:
MCPToolUtils.resolveDatabase (:44-58). Guard: coarse canAccessToDatabase. Bypass proof: user can access DB; NO setCurrentUser call anywhere → thread context has null user.
- Processing:
QueryTool.execute → engine.analyze(query) (js engine) → checkScriptingPermissions() → database.checkPermissionsOnDatabase(UPDATE_SECURITY). Guard: UPDATE_SECURITY gate. Bypass proof: LocalDatabase.java:721-724 — null current user → return (no-op). Gate passes.
- Sink:
analyze() eagerly polyglotEngine.eval(query) (:196-206) BEFORE isIdempotent(); JS calls database.command('sql', <write/DDL>) (nested gate also no-ops).
- Impact: read-only MCP user performs arbitrary writes/schema/security mutation = full escalation.
Bypass Evidence
- Null-user no-op verified: LocalDatabase.java:715-730 early-returns on null user/context/security.
- MCP never binds a user:
grep setCurrentUser matches only non-MCP transports; MCPHttpHandler extends AbstractServerHttpHandler (not the binding handler).
- MCP defaults: enabled=false, allowReads=true, allowedUsers=["root"], allowAdmin=false;
isUserAllowed supports "*".
- js eager-eval-before-idempotent verified at PolyglotQueryEngine.java:196-206.
Affected Versions
<= 26.7.2.
Preconditions
MCP enabled=true (default false) + a non-root or "*" entry in allowedUsers (both explicitly supported for agent delegation). The query+js sub-case needs only default allowReads=true.
Suggested Fix
Bind the principal in MCP tool execution (DatabaseContext.INSTANCE.init(db).setCurrentUser(user.getDatabaseUser(db)), cleared in finally), mirroring DatabaseAbstractHandler.
Reported by zx (Jace) — GitHub: @manus-use
Summary
The ArcadeDB MCP (Model Context Protocol) HTTP transport is the only command transport that never binds the authenticated principal onto the request thread's
DatabaseContext. The engine's authorization primitives are deliberately no-ops when no user is bound, so every permission gate silently passes for MCP callers. A non-root MCP-allowed user can perform arbitrary writes, DDL, schema and security mutations, and (via the js query sub-case) execute arbitrary in-JVM JavaScript.Root Cause
DatabaseAbstractHandler.java:88-90current.setCurrentUser(user.getDatabaseUser(database)); Bolt/Postgres/gRPC similarly).LocalDatabase.checkPermissionsOnDatabase(LocalDatabase.java:715-730) andcheckPermissionsOnFile(:733) return early (no-op) whensecurity==null,dbContext==null, orgetCurrentUser()==null— the mechanism embedded/HA-apply contexts use to skip checks.MCPHttpHandler extends AbstractServerHttpHandler, NOTDatabaseAbstractHandler) resolves the DB only viaMCPToolUtils.resolveDatabase(coarsecanAccessToDatabase) and NEVER callssetCurrentUser.grep setCurrentUsermatches only gRPC/Bolt/HTTP-DatabaseAbstractHandler/Postgres.QueryTool.executechecks onlyisAllowReads()(default true), thenengine.analyze(query); forlanguage=js,PolyglotQueryEngine.analyze()(PolyglotQueryEngine.java:196-206) eagerlyeval()s the script BEFOREQueryTool'sisIdempotent()check — so a read-only MCP user runs arbitrary JS.Impact
A non-root MCP-allowed reader performs arbitrary writes / schema / security mutation / in-JVM JS execution. Re-opens GHSA-48qw, GHSA-vwjc, GHSA-8vr5, and CVE-2026-54077 for MCP callers. C:H/I:H/A:H.
Attack Chain (query+js sub-case — needs only default allowReads)
POST /api/v1/mcpBasic-auth as a non-root MCP-allowed reader →tools/call name=query {database, language:"js", query:"database.command('sql','UPDATE User SET role=...'); 1"}. Guard:MCPDispatcher.dispatchauth +isUserAllowed;QueryToolchecksisAllowReads()(default true). Bypass proof: reader is allowed; allowReads default true.MCPToolUtils.resolveDatabase(:44-58). Guard: coarsecanAccessToDatabase. Bypass proof: user can access DB; NOsetCurrentUsercall anywhere → thread context has null user.QueryTool.execute→engine.analyze(query)(js engine) →checkScriptingPermissions()→database.checkPermissionsOnDatabase(UPDATE_SECURITY). Guard: UPDATE_SECURITY gate. Bypass proof: LocalDatabase.java:721-724 — null current user → return (no-op). Gate passes.analyze()eagerlypolyglotEngine.eval(query)(:196-206) BEFOREisIdempotent(); JS callsdatabase.command('sql', <write/DDL>)(nested gate also no-ops).Bypass Evidence
grep setCurrentUsermatches only non-MCP transports;MCPHttpHandlerextendsAbstractServerHttpHandler(not the binding handler).isUserAllowedsupports "*".Affected Versions
<= 26.7.2.Preconditions
MCP
enabled=true(default false) + a non-root or"*"entry inallowedUsers(both explicitly supported for agent delegation). The query+js sub-case needs only defaultallowReads=true.Suggested Fix
Bind the principal in MCP tool execution (
DatabaseContext.INSTANCE.init(db).setCurrentUser(user.getDatabaseUser(db)), cleared in finally), mirroringDatabaseAbstractHandler.Reported by zx (Jace) — GitHub: @manus-use