* fix(mcp): close HTTP MCP shell-job RCE + tighten remote contract
The HTTP MCP transport in serve-http.ts inlined its own OperationContext
literal and forgot to set `remote: true`. With the field undefined at the
operations.ts protected-job-name guard (line 1391), an HTTP MCP caller
holding a write-scoped OAuth token could submit `submit_job {name: "shell"}`
and execute arbitrary commands on the gbrain host (RCE-class).
Two-layer fix:
1. F7 — explicit `remote: true` on the inlined /mcp OperationContext.
Stdio MCP at src/mcp/dispatch.ts:61 already set this; the HTTP path
was the regression.
2. F7b — fail-closed contract on the four ctx.remote consumer sites in
operations.ts (auto-link skip, telemetry x2, protected-job guard).
The protected-job guard flips from `if (ctx.remote && ...)` to
`if (ctx.remote !== false && ...)` and the trusted-marker site flips
from `!ctx.remote && ...` to `ctx.remote === false && ...`. Anything
that isn't strictly `false` now treats the caller as remote/untrusted.
3. D12 — `OperationContext.remote` becomes REQUIRED in the TypeScript
type. The compiler now catches future transports that forget the field.
The runtime fail-closed defaults are belt+suspenders for any caller
that bypasses the type via `as` cast or `Partial<>` spread.
Tests:
- New `test/trust-boundary-contract.test.ts` (4 cases) pins the
fail-closed semantics: undefined-via-cast rejects, remote=true rejects,
remote=false allowed (only path that escalates protected-name jobs).
- `test/e2e/serve-http-oauth.test.ts` adds 2 cases asserting HTTP MCP
cannot submit `shell` or `subagent` jobs even with read+write scope.
- `test/e2e/graph-quality.test.ts` adds the now-required `remote: false`
to its fixture (e2e graph quality simulates local-CLI writes).
Verification: bun test -> 3742 pass / 0 fail. typecheck clean.
Thanks to @ElectricSheepIO on X for the security review that surfaced
this trust-boundary regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oauth): RFC 6749 hardening + serve-http defense in depth
OAuth provider hardening pass that brings the provider into RFC compliance
on auth code, refresh token, and revocation flows, and tightens the
serve-http surface around request logging and admin cookies.
Provider (src/core/oauth-provider.ts):
- F1: bind client_id atomically into the auth code DELETE WHERE clause for
exchangeAuthorizationCode + challengeForAuthorizationCode. Previous
pattern (DELETE...RETURNING then post-hoc client compare) burned codes
on the wrong-client path so the legitimate client could not retry.
RFC 6749 §10.5.
- F2: same atomic predicate on exchangeRefreshToken. The pre-fix shape
defeated RFC 6749 §10.4's stolen-token detection by letting attacker +
victim both succeed.
- F3: refresh token rejects requested scopes that are not a subset of the
ORIGINAL grant on the row. Codex C9: subset is checked against the
recorded grant, not the client's currently-allowed scopes (which can
expand later); omitted scope inherits the original verbatim and stays
distinct from explicit-empty. RFC 6749 §6.
- F4: revokeToken adds AND client_id to the DELETE so a client cannot
revoke another client's tokens by guessing the hash. RFC 7009 §2.1.
- F5: deleted_at and token_ttl column probes use a new
isUndefinedColumnError helper (extracted to src/core/utils.ts per D14)
that matches SQLSTATE 42703 or column-name-in-message. Bare catch{}
used to swallow lock timeouts, network blips, and auth failures as
"column missing" — fail-open posture in a security path.
- F6: sweepExpiredTokens uses RETURNING 1 + array length. Pre-fix
(result as any).count returned 0 on at least one engine even when
rows were deleted, and codes were never counted.
- F7c: NEW finding eva-brain missed. exchangeAuthorizationCode now folds
redirect_uri into the atomic DELETE predicate when the parameter is
provided. Stored on /authorize, never compared on /token before this
commit. RFC 6749 §4.1.3 violation. Back-compat: when caller omits the
parameter the predicate is skipped, preserving SDK consumers that
haven't adopted the parameter yet.
- F12 (cleanup, not security): dcrDisabled constructor option replaces
the prior monkey-patch of _clientsStore in serve-http.ts. The SDK's
mcpAuthRouter only wires up /register when the store exposes
registerClient, so omitting the method via the constructor is
sufficient. Reframed as cleanup per codex C10 — the monkey-patch
happened before mcpAuthRouter ran, so the prior shape did not have
a real security regression to claim.
Dispatch (src/mcp/dispatch.ts):
- F8: new summarizeMcpParams(opName, params) intersects submitted keys
against the operation's declared params allow-list. Returns
{redacted, kind, declared_keys, unknown_key_count, approx_bytes}.
Closes the codex C8 leak: a naive "dump all submitted keys" summary
still echoed attacker-controlled key names like
put_page {"wiki/people/sensitive_name": "..."} into mcp_request_log
+ the SSE feed. Allow-list pattern keeps debug visibility on declared
keys while counting unknowns without naming them.
Serve-http (src/commands/serve-http.ts) + serve (src/commands/serve.ts):
- F8 wiring: mcp_request_log + SSE broadcast routed through
summarizeMcpParams by default. New --log-full-params flag bypasses
redaction with a loud stderr warning at startup. Default privacy-
positive; flag is the documented escape hatch for self-hosted
operators debugging on their own laptop.
- F9: admin cookies set Secure when req.secure OR issuerUrl.protocol
is https. Cloudflare-tunnel + reverse-proxy deployments where the
inside-tunnel hop looks like http but the public URL is https now
tag cookies correctly.
- F10: bound magicLinkNonces with NONCE_LRU_CAP. Previously only the
consumed-nonces map was capped; an attacker (or misbehaving agent)
with the bootstrap token could mint nonces faster than they expired
and grow the live store unbounded.
- F12: dcrDisabled flows through to the provider constructor instead of
monkey-patching _clientsStore after construction.
- F14: try/catch wraps StreamableHTTPServerTransport setup +
handleRequest. SDK-level throws no longer fall through to express's
default HTML error page; clients expecting JSON-RPC envelopes get a
JSON 500 instead.
- F15: error envelope unified via buildError + serializeError from
src/core/errors.ts. OperationError and unexpected exceptions both
emit the same {class, code, message, hint} shape so clients can
pattern-match a single envelope.
Tests:
- test/oauth.test.ts adds 11 cases:
* F1+F2 wrong-client cannot consume / read PKCE / burn refresh,
paired with owner-still-redeems atomically afterward (codex D6 —
proves the predicate doesn't burn the row on attacker attempts).
* F3 refresh scope subset enforced.
* F4 wrong-client cannot revoke.
* F5 non-schema SQL not swallowed by client_credentials soft-delete probe.
* F6 sweepExpiredTokens returns count > 0 after deleting rows.
* F7c redirect_uri match succeeds, mismatch rejects, omitted preserves
back-compat for callers that don't pass the parameter.
* F12 dcrDisabled constructor option exposes only getClient,
registerClientManual still works.
- test/mcp-dispatch-summarize.test.ts (NEW, 6 cases): pins the F8
privacy invariants. The codex-C8 attacker-key-name probe asserts that
a sensitive name submitted as a key never appears anywhere in the
redactor's output.
Verification: bun run typecheck clean. test/oauth.test.ts 55/55,
test/mcp-dispatch-summarize.test.ts 6/6,
test/trust-boundary-contract.test.ts 4/4 from commit A. The one
unrelated unit failure surfaces on master too — environment-sensitive
test that expects ~/.gbrain/config.json to be absent in the test env.
Out of scope: F11 (auth register-client --redirect-uri flag) and F13
(serve --http argv positive-int validator) per codex C11 — operator
UX gaps, not trust-boundary fixes. Filed as follow-up TODOs.
Thanks to @ElectricSheepIO on X for the security review that surfaced
this hardening pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: file F11 + F13 as OAuth hardening follow-up TODOs
Codex C11 flagged these as scope creep on the v0.26.7 OAuth hardening
PR (operator UX, not trust-boundary). Capturing them here so the
context survives — eva-brain has both implementations and the lift is
mechanical when we want to do them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oauth): close adversarial-review findings on F7c + F8
Two bugs surfaced by an adversarial subagent during /ship's pre-landing
review pass that the codex + plan-eng-review didn't catch.
D15 / F7c: `exchangeAuthorizationCode` used `redirectUri ? ...` ternary
to choose the with-redirect vs no-redirect SQL. Empty string fell
through to the no-redirect branch, so a caller submitting
`redirect_uri=""` at /token bypassed the binding entirely. RFC 6749
§4.1.3 spec violation. Switch to `redirectUri !== undefined`. Test:
empty-string redirect_uri must reject when /authorize stored a real URI.
D16 / F8: `summarizeMcpParams` published exact byte length via
`approx_bytes = JSON.stringify(params).length`. Submitting put_page with
a known prefix and observing the resulting log entry across repeated
probes lets an attacker binary-search the size of secret suffix content.
Bucket to 1KB resolution. The redacted summary keeps a coarse
"roughly how big" signal for operators while making size-based
side-channel attacks useless.
Test count: 65 → 67 across the three new test files.
Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.26.9)
OAuth 2.1 hardening + HTTP MCP shell-job RCE fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update project documentation for v0.26.9
Annotate CLAUDE.md key-files entries with v0.26.9 OAuth/MCP hardening pass:
- src/core/operations.ts: D12 (OperationContext.remote required) + F7b
(4-site fail-closed flip), HTTP MCP shell-job RCE close
- src/core/utils.ts: D14 isUndefinedColumnError extracted helper
- src/mcp/dispatch.ts: F8 summarizeMcpParams privacy redactor with
declared-keys allow-list + 1KB byte bucketing
- src/commands/serve-http.ts: F7+F8+F9+F10+F12+F14+F15 hardening
- src/core/oauth-provider.ts: F1+F2+F3+F4+F5+F6+F7c+F12 RFC 6749/7009
hardening pass
Add new test-file entries for test/mcp-dispatch-summarize.test.ts
(7 cases) and test/trust-boundary-contract.test.ts (4 cases). Extend
test/oauth.test.ts (+14 cases) and test/e2e/serve-http-oauth.test.ts
(+2 RCE-close regressions) entries with v0.26.9 case counts.
README.md: added --log-full-params to gbrain serve --http surface.
SECURITY.md: documented mcp_request_log.params redaction default
({redacted, kind, declared_keys, unknown_key_count, approx_bytes}) +
--log-full-params opt-in.
docs/mcp/DEPLOY.md: operator-facing note on SSE feed + audit log
redaction default and when to flip --log-full-params on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.9 KiB
Deploy GBrain Remote MCP Server
v0.26.0+:
gbrain serve --httpships full OAuth 2.1 (client credentials, auth code + PKCE, refresh rotation, optional DCR), an embedded React admin dashboard at/admin, scoped operations, and a live SSE activity feed. Pre-v0.26 legacy bearer tokens still work —verifyAccessTokenfalls back to theaccess_tokenstable and grandfathers tokens toread+write+admin. Postgres-only for the legacy fallback (theaccess_tokenstable is Postgres-only); OAuth tables work on both PGLite and Postgres. See SECURITY.md for env vars and tunable defaults.
Access your brain from any device, any AI client. GBrain ships two transports:
gbrain serve (stdio) for local agents, and gbrain serve --http (v0.26.0+)
for remote clients over OAuth 2.1.
Three Paths
Local stdio (zero setup)
gbrain serve
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio. No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
Remote over OAuth 2.1 (recommended, v0.26.0+)
gbrain serve --http --port 3131
ngrok http 3131 --url your-brain.ngrok.app
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
Built-in HTTP transport with OAuth 2.1, scoped operations, an admin dashboard
at /admin, and a live SSE activity feed. Zero external dependencies. This is
the only path that works with ChatGPT (OAuth 2.1 + PKCE is required by the
ChatGPT MCP connector). Pass --public-url whenever the server is reachable
at anything other than http://localhost:<port> so the OAuth issuer in
discovery metadata matches what clients hit (RFC 8414 §3.3).
Supported clients:
- ChatGPT — requires OAuth 2.1 + PKCE. Works natively with
--http. - Claude Desktop / Cowork — OAuth 2.1 or legacy bearer tokens.
- Perplexity — OAuth 2.1 client credentials grant.
- Claude Code, Cursor, Windsurf — can use OAuth or legacy bearer.
See the OAuth 2.1 setup section below.
Remote with legacy bearer tokens (pre-v0.26 deployments) — Postgres only
Your AI client (Claude Desktop, Perplexity, etc.)
→ ngrok tunnel (https://YOUR-DOMAIN.ngrok.app)
→ gbrain serve --http (built-in transport with bearer auth)
→ Postgres (pooler connection or self-hosted)
This requires:
- A Postgres-backed brain (the
access_tokenstable only exists on Postgres; runninggbrain serve --httpagainst a PGLite install fails fast at startup) - A machine running
gbrain serve --http - A public tunnel (ngrok, Tailscale, or cloud host)
- A bearer token created via
gbrain auth create <name>
Pre-v1.0 tokens are grandfathered as read+write+admin scopes when you upgrade
to the HTTP server, so no migration is required.
OAuth 2.1 Setup (v0.26.0+)
1. Start the HTTP server
gbrain serve --http --port 3131
On first start, the server prints an admin bootstrap token to stderr:
Admin bootstrap token: 3a1f9c...
Open http://localhost:3131/admin and paste it to log in.
Save this token. Open http://localhost:3131/admin and paste it to access the
dashboard. The dashboard shows live activity, registered clients, request logs,
and per-client config export.
v0.26.9+:
mcp_request_log.paramsand the live SSE activity feed default to a redacted summary{redacted, kind, declared_keys, unknown_key_count, approx_bytes}. Declared param keys are kept (intersected against the operation's spec); unknown keys are counted but never named, and byte sizes round up to 1KB so size-probe attacks can't binary-search secret content. Operators on a personal laptop who want raw payloads back can passgbrain serve --http --log-full-params(loud stderr warning fires at startup). Multi-tenant deployments should leave it on the redacted default.
2. Register OAuth clients
Register clients from the /admin dashboard:
- Click Register client.
- Enter a name (e.g.
perplexity,chatgpt). - Pick scopes:
read,write,admin(checkboxes). - Pick grant type:
client_credentialsfor machine-to-machine (Perplexity, Claude Desktop bearer mode) orauthorization_codefor browser-based clients with PKCE (ChatGPT). - For
authorization_codeclients, paste the redirect URI. - Hit Register. The credential-reveal modal shows the
client_id(andclient_secretfor confidential clients) once. Copy or Download JSON immediately — secrets are hashed on storage and never shown again.
Or from the CLI — faster for scripting:
gbrain auth register-client perplexity \
--grant-types client_credentials \
--scopes "read write"
Host-repo wrappers can register programmatically:
await oauthProvider.registerClientManual(
'perplexity',
['client_credentials'],
'read write',
[], // redirect_uris, empty for CC
);
For self-service client registration (Dynamic Client Registration, RFC 7591),
start the server with --enable-dcr. DCR is off by default.
3. Expose the server
brew install ngrok
ngrok config add-authtoken YOUR_TOKEN
ngrok http 3131 --url your-brain.ngrok.app
Your OAuth issuer URL becomes https://your-brain.ngrok.app. The MCP SDK's
router exposes the spec-compliant discovery endpoint at
/.well-known/oauth-authorization-server.
4. Scopes and localOnly
Every operation is tagged read | write | admin. Four operations are
localOnly and rejected over HTTP regardless of scope: sync_brain,
file_upload, file_list, file_url. Remote agents cannot reach local
filesystem surface area.
| Scope | What it allows |
|---|---|
read |
search, query, get_page, list_pages, graph traversal |
write |
put_page, delete_page, add_link, add_timeline_entry |
admin |
Client management, token revocation, sweep, local-only ops |
Legacy Bearer Token Setup
Keep using pre-v0.26 bearer tokens if you aren't ready to migrate. They
grandfather to read+write+admin scopes on the HTTP server.
1. Set up the tunnel
See the ngrok-tunnel recipe for full setup. Quick version:
brew install ngrok
ngrok config add-authtoken YOUR_TOKEN
ngrok http 8787 --url your-brain.ngrok.app # Hobby tier for fixed domain
2. Create access tokens
# Create a token for each client
gbrain auth create "claude-desktop"
# List all tokens
gbrain auth list
# Revoke a token
gbrain auth revoke "claude-desktop"
Tokens are per-client. Create one for each device/app. Revoke individually if compromised. Tokens are stored SHA-256 hashed in your database.
3. Connect your AI client
- ChatGPT: setup guide (OAuth 2.1 + PKCE, requires
gbrain serve --http) - Claude Code: setup guide
- Claude Desktop: setup guide (must use GUI, not JSON config)
- Claude Cowork: setup guide
- Perplexity: setup guide
4. Verify
gbrain auth test \
https://YOUR-DOMAIN.ngrok.app/mcp \
--token YOUR_TOKEN
Operations
All 30 GBrain operations are available remotely, including sync_brain and
file_upload (no timeout limits with self-hosted server).
Security note on file_upload: remote MCP callers are confined to the working
directory where gbrain serve was launched. Symlinks, .. traversal, and absolute
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
CLI callers (gbrain file upload ...) keep unrestricted filesystem access since
the user owns the machine.
Deployment Options
See ALTERNATIVES.md for a comparison of ngrok, Tailscale Funnel, and cloud hosts (Fly.io, Railway).
Troubleshooting
"missing_auth" error
Include the Authorization header: Authorization: Bearer YOUR_TOKEN
"invalid_token" error
Run gbrain auth list to see active tokens.
"service_unavailable" error Database connection failed. Check your Supabase dashboard for outages.
Claude Desktop doesn't connect
Remote servers must be added via Settings > Integrations, NOT
claude_desktop_config.json. See CLAUDE_DESKTOP.md.
Expected Latencies
| Operation | Typical Latency | Notes |
|---|---|---|
| get_page | < 100ms | Single DB query |
| list_pages | < 200ms | DB query with filters |
| search (keyword) | 100-300ms | Full-text search |
| query (hybrid) | 1-3s | Embedding + vector + keyword + RRF |
| put_page | 100-500ms | Write + trigger search_vector update |
| get_stats | < 100ms | Aggregate query |
Note: gbrain serve --http shipped in v0.26.0 with OAuth 2.1 + admin
dashboard baked into the binary. The custom HTTP wrapper pattern (see
voice recipe) is still supported for
teams that need bespoke middleware, but for most remote deployments the
built-in server is the recommended path.