Skip to content

MCP per-tool scope enforcement not implemented — read-only tokens can invoke admin tools #20

@sc-moore

Description

@sc-moore

Severity: Critical
Type: Broken Access Control (CWE-285: Improper Authorization)
Affected versions: v0.18.1 (current) and likely all prior versions with MCP support

Summary

The MCP server defines per-tool scope requirements (read, operator, admin) in src/mcp/auth.ts but never enforces them during request handling. Any authenticated token, regardless of its assigned scopes, can invoke any MCP tool — including phantom_register_tool, which is intended to require admin scope and enables arbitrary shell command execution.

Impact

A read-scoped token (intended for monitoring only) can:

  1. Call phantom_register_tool to register a new tool with an arbitrary shell command as its handler
  2. Invoke that tool to execute the command on the host

This provides remote code execution to any holder of any valid MCP token. In deployments where read-only tokens are distributed to dashboards, monitoring systems, or peer Phantom instances, this expands the attack surface well beyond what the token issuer intended.

Root Cause

TOOL_SCOPES and getRequiredScope() are defined in src/mcp/auth.ts:58-74 and correctly map tools to their required scopes. hasScope() at line 38-45 correctly implements scope hierarchy (admin implies all, operator implies read).

However, handleRequest() in src/mcp/server.ts:115-190 authenticates the token and checks rate limits, but never calls getRequiredScope() or hasScope() before delegating to the transport manager. The auth result (containing the token's scopes) is passed through but never consulted for per-tool authorization.

Reproduction

  1. Generate a read-only MCP token via phantom token create --scope read
  2. Send a tools/call JSON-RPC request to /mcp with the read-only bearer token, invoking phantom_register_tool with a shell handler (e.g., id)
  3. Send a second tools/call request invoking the newly registered tool
  4. Observe that the shell command executes successfully despite the token only having read scope

Suggested Fix

Add scope enforcement in handleRequest() after authentication. The required functions already exist — they just need to be called. Conceptually:

// After authentication and rate limiting, before delegating to transport:
const body = await req.clone().json();
if (body?.method === "tools/call" && body?.params?.name) {
    const requiredScope = getRequiredScope(body.params.name);
    if (!this.auth.hasScope(auth, requiredScope)) {
        return Response.json(
            { jsonrpc: "2.0", error: { code: -32001, message: `Insufficient scope: requires ${requiredScope}` }, id: body.id },
            { status: 403 }
        );
    }
}

Additionally, getRequiredScope() currently defaults unknown tool names to "read" (line 73). Dynamically registered tools would fall through to this default, meaning any authenticated user could invoke them. Consider defaulting to "operator" for unknown tools.

Additional Context

The /trigger endpoint in src/core/server.ts does correctly check for operator scope — this appears to be the intended pattern that was missed for the MCP tool invocation path.


Identified during a security audit with assistance from Claude.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions