From 6e740590b38573538e8d70eaef3eb6cbfe094cab Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 23:52:25 +0000 Subject: [PATCH] fix: add built-in HTTP transport with bearer auth for remote MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `gbrain serve --http` with token-based authentication using the existing access_tokens table. Eliminates the need for standalone OAuth wrappers that may have insecure open registration endpoints. - New: src/mcp/http-transport.ts — HTTP+SSE transport with bearer auth - New: SECURITY.md — security advisory for remote MCP deployments - Updated: serve command accepts --http and --port flags - Updated: DEPLOY.md recommends --http for remote access - Bump: 0.22.4 → 0.22.5 --- CHANGELOG.md | 8 ++ SECURITY.md | 64 ++++++++++++++ docs/mcp/DEPLOY.md | 3 + package.json | 2 +- src/cli.ts | 2 +- src/commands/serve.ts | 18 +++- src/mcp/http-transport.ts | 178 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 SECURITY.md create mode 100644 src/mcp/http-transport.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa319bf3..9a16627cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog All notable changes to GBrain will be documented in this file. +## [0.22.5] - 2026-04-27 + +### Security hotfix: HTTP transport for remote MCP + +- **`gbrain serve --http`** — built-in HTTP transport with bearer token auth. Uses the existing `access_tokens` table. No OAuth, no registration endpoint. This is the recommended way to expose GBrain remotely. +- **SECURITY.md** — added security advisory and guidance for remote MCP deployments. +- Standalone OAuth wrappers with open client registration should require a registration secret. See SECURITY.md for details. + ## [0.22.4] - 2026-04-26 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..07b39c4ca --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# Security + +## Reporting Vulnerabilities + +If you discover a security issue in GBrain, please report it privately: + +- **Email:** security@garrytan.com +- **GitHub:** Open a [private security advisory](https://github.com/garrytan/gbrain/security/advisories/new) + +Do not open a public issue for security vulnerabilities. + +## Remote MCP Security + +### ⚠️ Do NOT use open OAuth client registration for remote MCP + +If you deploy GBrain's MCP server behind an HTTP wrapper with OAuth 2.1 +support, **never allow unauthenticated client registration**. An attacker +who discovers your server URL can: + +1. Register a new OAuth client via `POST /register` +2. Use `client_credentials` grant to obtain a bearer token +3. Access all brain data via the MCP tools + +### Recommended: `gbrain serve --http` + +As of v0.22.5, GBrain ships a built-in HTTP transport that uses the +existing `access_tokens` table for authentication: + +```bash +# Create a token +gbrain auth create "my-client" + +# Start the HTTP server +gbrain serve --http --port 8787 + +# Connect via ngrok, Tailscale, or any tunnel +ngrok http 8787 --url your-brain.ngrok.app +``` + +This is the recommended way to expose GBrain remotely. No OAuth, no +registration endpoint, no self-service tokens. Tokens are managed +exclusively via `gbrain auth create/list/revoke`. + +### If you must use a custom HTTP wrapper + +1. **Require a secret for client registration** — check a header or body + parameter before creating new OAuth clients +2. **Disable `client_credentials` grant** — only allow `authorization_code` + with browser-based approval +3. **Restrict scopes** — never issue tokens with unlimited scope +4. **Log all token issuance** — alert on unexpected registrations +5. **Rate-limit registration and token endpoints** + +### Token Management + +```bash +gbrain auth create "claude-desktop" # Create a new token +gbrain auth list # List all tokens +gbrain auth revoke "claude-desktop" # Revoke a token +gbrain auth test --token # Smoke-test a remote server +``` + +Tokens are stored as SHA-256 hashes in the `access_tokens` table. The +plaintext token is shown once at creation and never stored. diff --git a/docs/mcp/DEPLOY.md b/docs/mcp/DEPLOY.md index 7370b34f3..e24260382 100644 --- a/docs/mcp/DEPLOY.md +++ b/docs/mcp/DEPLOY.md @@ -1,5 +1,8 @@ # Deploy GBrain Remote MCP Server +> **v0.22.5+:** Use `gbrain serve --http` for remote access. It includes built-in +> bearer token auth with no OAuth registration surface. See [SECURITY.md](../../SECURITY.md). + Access your brain from any device, any AI client. GBrain's MCP server runs locally via `gbrain serve` (stdio). For remote access, wrap it in an HTTP server behind a public tunnel. diff --git a/package.json b/package.json index 932060d00..fb31c277a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.22.4", + "version": "0.22.5", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/cli.ts b/src/cli.ts index 629b8ed4b..e6273fc26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -447,7 +447,7 @@ async function handleCliOnly(command: string, args: string[]) { } case 'serve': { const { runServe } = await import('./commands/serve.ts'); - await runServe(engine); + await runServe(engine, args); return; // serve doesn't disconnect } case 'call': { diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 5e5d7f4e5..6625716f2 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,7 +1,19 @@ import type { BrainEngine } from '../core/engine.ts'; import { startMcpServer } from '../mcp/server.ts'; +import { startHttpTransport } from '../mcp/http-transport.ts'; -export async function runServe(engine: BrainEngine) { - console.error('Starting GBrain MCP server (stdio)...'); - await startMcpServer(engine); +export async function runServe(engine: BrainEngine, args: string[] = []) { + const useHttp = args.includes('--http'); + const portIdx = args.indexOf('--port'); + const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) || 8787 : 8787; + + if (useHttp) { + console.error(`Starting GBrain MCP server (HTTP on port ${port})...`); + await startHttpTransport({ port, engine }); + // Keep alive + await new Promise(() => {}); + } else { + console.error('Starting GBrain MCP server (stdio)...'); + await startMcpServer(engine); + } } diff --git a/src/mcp/http-transport.ts b/src/mcp/http-transport.ts new file mode 100644 index 000000000..1f3d9623a --- /dev/null +++ b/src/mcp/http-transport.ts @@ -0,0 +1,178 @@ +/** + * HTTP transport for gbrain MCP server. + * + * `gbrain serve --http` starts an HTTP server on --port (default 8787) + * with bearer token authentication via the existing `access_tokens` table. + * + * Security model: + * - Every request must include `Authorization: Bearer ` + * - Tokens are validated against SHA-256 hashes in the `access_tokens` table + * - Create tokens with `gbrain auth create ` + * - No open OAuth registration, no client_credentials flow, no self-service tokens + * + * This replaces the need for a standalone HTTP+OAuth wrapper, which was + * vulnerable to unauthenticated client registration (see SECURITY.md). + */ + +import { createHash } from 'crypto'; +import type { BrainEngine } from '../core/engine.ts'; +import { operations, OperationError } from '../core/operations.ts'; +import type { OperationContext } from '../core/operations.ts'; +import { buildToolDefs } from './tool-defs.ts'; +import { VERSION } from '../version.ts'; + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +interface HttpTransportOptions { + port: number; + engine: BrainEngine; +} + +export async function startHttpTransport(opts: HttpTransportOptions) { + const { port, engine } = opts; + const ctx: OperationContext = { engine, remote: true }; + const tools = buildToolDefs(operations); + + async function validateToken(authHeader: string | null): Promise { + if (!authHeader?.startsWith('Bearer ')) return false; + const token = authHeader.slice(7); + const hash = hashToken(token); + try { + const sql = (engine as any).sql; // internal pool + const [row] = await sql` + SELECT id FROM access_tokens + WHERE token_hash = ${hash} AND revoked_at IS NULL + `; + if (row) { + // Update last_used_at (fire-and-forget) + sql`UPDATE access_tokens SET last_used_at = now() WHERE id = ${row.id}`.catch(() => {}); + } + return !!row; + } catch { + return false; + } + } + + const server = Bun.serve({ + port, + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + + // CORS preflight + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept', + }, + }); + } + + // Health check — no auth required, no sensitive data + if (path === '/health') { + return Response.json({ status: 'ok', version: VERSION, transport: 'http' }); + } + + // All other endpoints require auth + const authHeader = req.headers.get('Authorization'); + if (!(await validateToken(authHeader))) { + return Response.json( + { error: 'invalid_token', message: 'Bearer token required. Create one: gbrain auth create ' }, + { status: 401 }, + ); + } + + // MCP JSON-RPC endpoint + if (path === '/mcp' && req.method === 'POST') { + try { + const body = await req.json(); + const { method, params, id } = body; + + // tools/list + if (method === 'tools/list') { + return sseResponse({ result: { tools }, jsonrpc: '2.0', id }); + } + + // initialize + if (method === 'initialize') { + return sseResponse({ + result: { + protocolVersion: '2025-03-26', + serverInfo: { name: 'gbrain', version: VERSION }, + capabilities: { tools: {} }, + }, + jsonrpc: '2.0', + id, + }); + } + + // notifications/initialized — acknowledge + if (method === 'notifications/initialized') { + return new Response(null, { status: 204 }); + } + + // tools/call + if (method === 'tools/call') { + const { name, arguments: args } = params || {}; + const op = operations.find(o => o.name === name); + if (!op) { + return sseResponse({ + result: { content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }], isError: true }, + jsonrpc: '2.0', + id, + }); + } + + try { + const result = await op.handler(args || {}, ctx); + const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2); + return sseResponse({ + result: { content: [{ type: 'text', text }] }, + jsonrpc: '2.0', + id, + }); + } catch (e: any) { + const msg = e instanceof OperationError ? e.message : `Internal error: ${e.message}`; + return sseResponse({ + result: { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true }, + jsonrpc: '2.0', + id, + }); + } + } + + return Response.json({ error: 'unknown_method', message: `Unknown method: ${method}` }, { status: 400 }); + } catch (e: any) { + return Response.json({ error: 'parse_error', message: e.message }, { status: 400 }); + } + } + + return Response.json({ error: 'not_found' }, { status: 404 }); + }, + }); + + console.error(`GBrain HTTP MCP server running on port ${port}`); + console.error(` Health: http://localhost:${port}/health`); + console.error(` MCP: http://localhost:${port}/mcp`); + console.error(` Auth: Bearer token required (create with: gbrain auth create )`); + console.error(''); + console.error('⚠️ Do NOT use open OAuth registration for remote MCP access.'); + console.error(' Tokens are managed via: gbrain auth create/list/revoke'); + + return server; +} + +function sseResponse(data: any): Response { + const body = `event: message\ndata: ${JSON.stringify(data)}\n\n`; + return new Response(body, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }, + }); +}