* fix: add built-in HTTP transport with bearer auth for remote MCP
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
* chore: extract shared MCP dispatch + rate-limit modules
dispatch.ts is the single source of truth for stdio + HTTP transport: validateParams,
OperationContext build, handler invocation, error formatting. Server.ts refactored to
use it. Prevents the F1-F3 transport-drift bugs where stdio and HTTP independently
implemented dispatch logic differently (reversed args, missing context fields, no
param validation).
rate-limit.ts: bounded-LRU token-bucket. Tracks lastTouchedMs separately from
lastRefillMs so an exhausted key can't be reset by hammering past the TTL.
* feat: HTTP transport hardening + F1-F3 dispatch bug fixes
Rewrite of src/mcp/http-transport.ts on top of the new dispatch.ts and rate-limit.ts:
- F1 fix: dispatch via shared dispatchToolCall(ctx, params) — was reversed args
(params, ctx) before, would have crashed every real tools/call.
- F2 fix: full OperationContext (engine, config, logger, dryRun, remote) — was
only {engine, remote: true} before.
- F3 fix: validateParams runs on HTTP path — was skipped before.
- Engine.kind fail-fast: clear error message on PGLite (access_tokens table is
Postgres-only by design).
- CORS: default-deny via GBRAIN_HTTP_CORS_ORIGIN allowlist.
- Body cap: stream-counted via req.body reader, catches chunked transfers
without Content-Length. Default 1 MiB via GBRAIN_HTTP_MAX_BODY_BYTES.
- Rate limit: pre-auth IP bucket fires BEFORE DB lookup (limits brute-force
load), post-auth token-id bucket fires after auth (limits runaway clients).
Both bounded LRU with TTL prune.
- mcp_request_log: per-request audit row reusing the existing schema (v4).
- last_used_at SQL-level debounce: WHERE last_used_at < now() - interval
'60 seconds'. Race-tolerant under PgBouncer.
- Response shape: application/json (gbrain MCP tools don't stream).
Streamable-HTTP transport spec compliant for non-streaming responses.
- X-Forwarded-For honored only when GBRAIN_HTTP_TRUST_PROXY=1.
* feat: wire gbrain auth into the main CLI
The original PR's docs referenced 'gbrain auth create/list/revoke' but auth.ts
was a standalone script never wired to the CLI dispatcher. Running 'gbrain auth'
from the compiled binary returned 'Unknown command'.
- auth.ts: extract the dispatch into runAuth(args) + import.meta.main guard
so direct-script invocation still works (bun run src/commands/auth.ts ...).
- cli.ts: add 'auth' to CLI_ONLY set + handler in handleCliOnly that imports
runAuth and dispatches without requiring an engine connection (auth.ts
manages its own postgres() connection).
* test: HTTP transport unit + E2E coverage (23 + 8 cases)
test/http-transport.test.ts — 23 unit cases against mocked engine.sql:
- Auth: valid/missing/no-Bearer/unknown/revoked/health-bypass (1-6)
- F1+F2 round-trip via dispatch.ts (7) — regression guard for reversed args
- F3 invalid_params via validateParams (8) — regression guard
- Response Content-Type application/json, not SSE (9)
- CORS default-deny + allowlist + non-match (10-12)
- Body cap: Content-Length + chunked-transfer (13-14)
- Rate limit: refill, exhaust+Retry-After, LRU eviction, TTL prune,
pre-auth IP fires before DB, /health bypasses (15-20)
- mcp_request_log audit: success row + auth_failed row (21-22)
test/e2e/http-transport.test.ts — 8 cases against real Postgres:
- /health, tools/list, tools/call list_pages (real op round-trip),
revoked → 401, last_used_at debounce within 60s (asserts ONE update),
debounce 65s gap (asserts TWO updates), mcp_request_log row check,
invalid_params via real handler.
* docs: v0.22.7 CHANGELOG + SECURITY.md + DEPLOY.md
CHANGELOG: v0.22.7 release notes covering the F1-F3 dispatch fixes, the full
hardening surface (CORS default-deny, two-bucket rate limit, body cap, audit
log), and the upgrade path. Master's v0.22.6 schema-verify entry stitched in
above (preserving merge ordering).
SECURITY.md: full hardening reference for gbrain serve --http — Postgres-only
caveat, CORS allowlist, rate limit + tunnel caveat, body cap, audit log query,
GBRAIN_HTTP_TRUST_PROXY warning.
docs/mcp/DEPLOY.md: Postgres-only call-out, env var summary, fail-fast behavior
on PGLite.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: codex review follow-ups (DB-probing /health + XFF trust safety contract)
- /health now does SELECT 1 against Postgres and returns 503 + status:unhealthy
when the DB is unreachable. Prevents the failure mode where orchestration
sees green pods while clients get misleading 401s during a DB outage.
- SECURITY.md: tighten the GBRAIN_HTTP_TRUST_PROXY=1 guidance with the explicit
two-condition safety contract — gbrain bound to a private interface AND the
proxy strips client-supplied XFF. Without both, the flag enables IP spoofing
past the pre-auth rate limit.
- Tests: add 6b (/health DB-down → 503) + assert db:'ok' on the happy path.
Caught by codex adversarial review during /ship Step 11.
* docs: TODOS.md — v0.22.7 follow-ups (audit volume, validateParams enums, SSE, scopes)
* docs: update project documentation for v0.22.7
CLAUDE.md: document src/mcp/dispatch.ts, src/mcp/rate-limit.ts, and the
rewritten src/mcp/http-transport.ts in the Key files section. Add
test/http-transport.test.ts (23 unit cases) and test/e2e/http-transport.test.ts
(8 E2E cases) to the test inventories.
CHANGELOG.md: fix copy-paste version mismatches inside the v0.22.7 entry that
referenced v0.22.5 (header line + "To take advantage of" block).
README.md: replace the standalone bun-run auth invocation with the wired-in
gbrain auth CLI; add gbrain serve --http startup step to the Remote MCP
example; surface gbrain auth in the admin command list; link SECURITY.md
from the Remote MCP section so it's discoverable.
SECURITY.md: align "as of v0.22.5" callouts with the actual release version
(v0.22.7).
docs/mcp/DEPLOY.md: align v0.22.5+ callout with v0.22.7+; switch token-management
examples from `bun run src/commands/auth.ts` to `gbrain auth` now that auth is
in the main CLI.
docs/mcp/ALTERNATIVES.md: drop the "planned but not yet implemented" note for
gbrain serve --http; document that the built-in HTTP transport is the
recommended path.
docs/mcp/{CLAUDE_DESKTOP,CLAUDE_COWORK,CLAUDE_CODE,PERPLEXITY}.md: switch
token-creation examples from `bun run src/commands/auth.ts create` to
`gbrain auth create` to match the wired-in CLI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: typecheck — cast CallToolRequestSchema handler return to any
MCP SDK 1.29 widened the response type for setRequestHandler(CallToolRequestSchema, ...)
to require a 'task' field for managed-task responses. gbrain ops are synchronous and
return the legacy { content, isError? } shape, which is still valid via the SDK's
ServerResult union. Casting the handler return type to any silences the narrowing
that broke after dispatch.ts was extracted (the original inline handler dodged this
because TypeScript inferred its return as any from the function body).
CI failure: src/mcp/server.ts(25,51): error TS2345 — Property 'task' is missing in
type 'ToolResult' but required in type '{ ...; task: { taskId: string; ... }; ... }'.
Caught by the 'test' job's bun run typecheck step at PR #483 commit 65ea9e7.
* docs: regenerate llms-full.txt after master merge
The build-llms regen-drift guard fails when committed llms.txt + llms-full.txt
don't match what scripts/build-llms.ts produces from current source. Master's
v0.22.6.1 merge brought in new content (CLAUDE.md entries, CHANGELOG, etc.)
that hadn't been folded into the bundle. Running 'bun run build:llms' to sync.
llms.txt unchanged; llms-full.txt picks up the new entries.
* docs: CHANGELOG — scrub attack-surface enumeration from v0.22.7 entry
Per CLAUDE.md responsible-disclosure rule: 'when a release fixes a security
gap or a user-impacting bug, describe the fix functionally. Do not enumerate
the attack surface, quantify the exposure window, or highlight the most
sensitive records by name in public-facing artifacts.'
Removed:
- Lead-paragraph attack-chain ('attacker who discovers URL → POST /register
→ client_credentials → read entire brain'). Public-doc readers don't need
the directed probe path.
- 'Bug fixes folded in' section that itemized prior-version failure modes.
Reframed as a 'transport refactor' note in the For Contributors section,
describing the dispatch consolidation functionally without claiming the
prior version was broken in specific ways.
- 'Without the OAuth footgun' lead headline. The fix's mechanism (built-in
bearer auth via access_tokens) is already self-evident from the headline.
- F1/F2/F3 internal labels and 'caught by codex outside-voice during
planning' parenthetical.
Kept:
- The full hardening reference table (configuration / behavior, not exposure).
- 'gbrain serve --http' user-facing operator ergonomics.
- 'Postgres-only by design' known-limit framing.
- Dispatch consolidation as a contributor-facing single-source-of-truth note.
SECURITY.md left intact: its OAuth-deployment guidance is generic 'if you
deploy MCP behind a custom HTTP wrapper, here are the rules' framing, not
gbrain-version-specific exposure. That's defensible under the same rule.
* docs: SECURITY.md — drop unverified security@garrytan.com address
The address was in the original PR's SECURITY.md commit (6e740590, author
'root <root@localhost>' — machine-generated) and never verified to exist or
forward anywhere. A non-monitored disclosure address is worse than no address
at all: reports go to a black hole.
Keep the GitHub private security advisory link as the sole disclosure channel.
GitHub Security Advisories is the working path most researchers reach for
first anyway — restricted-access by default, scopes the conversation to
maintainers, and integrates with CVE issuance when needed.
---------
Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
5.9 KiB
Security
Reporting Vulnerabilities
If you discover a security issue in GBrain, please report it privately by opening a private security advisory on GitHub.
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:
- Register a new OAuth client via
POST /register - Use
client_credentialsgrant to obtain a bearer token - Access all brain data via the MCP tools
Recommended: gbrain serve --http
As of v0.22.7, GBrain ships a built-in HTTP transport that uses the
existing access_tokens table for authentication:
# 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
- Require a secret for client registration — check a header or body parameter before creating new OAuth clients
- Disable
client_credentialsgrant — only allowauthorization_codewith browser-based approval - Restrict scopes — never issue tokens with unlimited scope
- Log all token issuance — alert on unexpected registrations
- Rate-limit registration and token endpoints
Token Management
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 <url> --token <tok> # 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.
gbrain serve --http hardening (v0.22.7+)
The built-in HTTP transport ships with several layers of hardening on by default. All env vars below are optional; the defaults are intentionally conservative.
Postgres-only
gbrain serve --http requires a Postgres engine. PGLite is local-only by
design and the access_tokens / mcp_request_log tables don't exist in
the PGLite schema. Local agents continue to use stdio (gbrain serve).
Running --http against a PGLite-backed install fails fast with a clear
error message at startup.
CORS
Default-deny: no Access-Control-Allow-Origin header is sent unless an
allowlist is configured. To allow browser-based MCP clients:
GBRAIN_HTTP_CORS_ORIGIN=https://claude.ai gbrain serve --http --port 8787
# Multiple origins: comma-separated
GBRAIN_HTTP_CORS_ORIGIN=https://claude.ai,https://your.app gbrain serve --http
When the request Origin matches the allowlist, the server echoes it
back in Access-Control-Allow-Origin (with Vary: Origin). Otherwise no
CORS header is sent and the browser blocks the request.
Rate limiting
Two buckets, both stored in a bounded LRU map (default 10K keys, evicts least-recently-used on overflow, prunes entries older than 2× the window):
| Bucket | When it fires | Default | Env var |
|---|---|---|---|
| Pre-auth IP | Before the DB lookup, on every /mcp request |
30 req / 60s | GBRAIN_HTTP_RATE_LIMIT_IP |
| Post-auth token | After a valid token is resolved | 60 req / 60s | GBRAIN_HTTP_RATE_LIMIT_TOKEN |
| LRU cap | Maximum distinct keys across both buckets | 10000 | GBRAIN_HTTP_RATE_LIMIT_LRU |
On exhaustion the server returns 429 Too Many Requests with a
Retry-After header.
Caveat for tunneled deployments (ngrok, Tailscale Funnel, Cloudflare Tunnel): all requests share one egress IP, so the pre-auth IP bucket becomes effectively shared by all clients on that tunnel. The post-auth token-id bucket is the load-bearing limiter for tunnel-fronted deployments.
Reverse-proxy trust
Disabled by default. To honor X-Forwarded-For (or X-Real-IP) when
gbrain runs behind a trusted reverse proxy:
GBRAIN_HTTP_TRUST_PROXY=1 gbrain serve --http --port 8787
Critical safety contract: only set GBRAIN_HTTP_TRUST_PROXY=1 when
both of these are true:
- gbrain is reachable only via a trusted reverse proxy (not directly
exposed to the internet on the configured port). The simplest
guarantee is to bind gbrain to
127.0.0.1or a private interface and have the proxy forward to it. - The proxy strips any client-supplied
X-Forwarded-ForandX-Real-IPheaders, then sets them itself. (nginx withproxy_set_header X-Forwarded-For $remote_addrdoes this; Cloudflare and most cloud load balancers handle it automatically.)
If gbrain is reachable directly AND GBRAIN_HTTP_TRUST_PROXY=1 is set,
clients can spoof their IP by sending arbitrary X-Forwarded-For
headers, defeating the pre-auth IP rate limit. Without the flag, gbrain
ignores all forwarded-for headers and uses the socket peer address,
which is the safe default for direct-exposure deployments.
Body size cap
Default 1 MiB, stream-counted (chunked transfers without
Content-Length are still capped). Override:
GBRAIN_HTTP_MAX_BODY_BYTES=2097152 gbrain serve --http # 2 MiB
Over-cap requests get 413 Payload Too Large immediately, before any
body is materialized in memory.
Audit log
Every /mcp request writes one row to mcp_request_log:
psql "$DATABASE_URL" -c \
"SELECT created_at, token_name, operation, status, latency_ms
FROM mcp_request_log
ORDER BY created_at DESC LIMIT 100"
status is one of: success, error, auth_failed, rate_limited,
body_too_large, parse_error, unknown_method. Failed-auth rows have
token_name = NULL. Inserts are fire-and-forget so audit failures
never block requests.