mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(security): confine routing dotfiles, skills dir, slugs, and transcription exec Shared src/core/path-confine.ts consolidates the realpath-containment idiom (moved from sources-ops.ts) and adds isTrustedDotfile + isWriteTargetContained. - .gbrain-source (source-resolver) and .gbrain-mount (brain-resolver) walk-up dotfiles are now lstat trust-gated: a symlink, foreign-owned, or world-writable file is refused on multi-user hosts (#418), fail-closed on stat error. - resolveWorkspaceSkillsDir + every skills-dir tier (env, cwd_walk_up, repo_root, cwd_skills, install_path) route through realpath containment so a symlinked workspace/skills can't escape the declared workspace (#419). - resolveSourceId/resolveBrainId realpath both sides of the registered local_path / mount prefix match so a symlinked cwd can't misattribute source/brain. - validateSlug rejects NUL/control, bidi/RTL overrides, backslashes, and URL-encoded path separators at the shared putPage/updateSlug chokepoint; write-through confirms the file path stays within the source tree. - transcribeLargeFile uses execFileSync arg-arrays + fs.rmSync (no shell), so a path with shell metacharacters is never parsed by a shell (#245). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): default dynamic-registration clients to authorization_code Self-registered DCR clients (the unauthenticated network registration path) previously defaulted to the client_credentials grant, which bypasses the /authorize consent screen. They now default to authorization_code; an explicit client_credentials request is rejected with invalid_client_metadata unless the operator opts in with the new --enable-dcr-insecure flag. A loud stderr WARNING prints at startup whenever DCR is enabled (#1353). Manual CLI/admin client registration is unchanged (operator-trusted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): schema-lint hardening migration (search_path + view security_invoker) Migration v120 brings existing brains to the same posture as fresh installs: - ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view honors the caller's RLS instead of the owner's (the view-through-RLS bypass). - ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions (both engines, IF EXISTS so engine-only functions are skipped; body untouched, so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171. - Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install no longer aborts (#1385). Fresh-install function definitions in schema.sql / pglite-schema.ts are born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a new CI guard (wired into verify) that fails if a trigger function in the schema base files is added without SET search_path. Postgres-only assertions live in the bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.55.0 fix(security): dotfile/skills/slug confinement, DCR consent default, schema-lint migration Bump VERSION + package.json to 0.42.55.0 and add the CHANGELOG entry for the security-hardening wave (#418 #419 #245 #1353 #1647 #171 #1385). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(security): note the DCR consent default in SECURITY.md (#1353) The "disable client_credentials, only allow authorization_code" guidance is now the built-in DCR default; document the new --enable-dcr-insecure escape hatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): add takes_search + code_def to the federated by-slug P1 (#2200) The v0.42.55.0 eng-review codex pass flagged takes_search (holder-allowlist only) and code_def (brain-wide raw SQL over content_chunks) as remaining same-class surfaces. Noted on the existing #2200 P1 follow-up, with the caveat that the #2399 close-list deliberately keeps #1371/#2200 open until this lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): correct plpgsql alias collision in #1385 BYPASSRLS gate (real-PG) The broadened BYPASSRLS preflight aliased `pg_roles r`, but several RLS DO-blocks already declare `r record` for their backfill FOR loop, so plpgsql resolved `r.oid`/`r.rolbypassrls` to the unassigned record variable → "record \"r\" is not assigned yet" on real Postgres (PGLite tolerated it; the DATABASE_URL-gated e2e jobs are the backstop). Renamed the subquery alias to `pr` at all 10 migrate.ts sites; also broadened the schema.sql base RLS gate the same way (with the `pr` alias) for #1385 consistency on superuser fresh installs, and regenerated schema-embedded.ts. Also fixes a PRE-EXISTING engine-parity bug (confirmed failing on clean origin/master): the relationalFanout shape compared `canonical_chunk_id`, a serial id that diverges between a fresh PGLite engine and a shared Postgres DB (setupDB TRUNCATEs without RESTART IDENTITY). Compare its presence, not the exact value. Validated on real Postgres (pgvector/pg16): migration v120 applies, the v35 RLS backfill runs, and engine-parity + postgres-bootstrap + jsonb-parity are green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
Markdown
273 lines
11 KiB
Markdown
# Security
|
||
|
||
## Reporting Vulnerabilities
|
||
|
||
If you discover a security issue in GBrain, please report it privately by opening
|
||
a [private security advisory](https://github.com/garrytan/gbrain/security/advisories/new)
|
||
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:
|
||
|
||
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.7, 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**
|
||
|
||
### Pre-registering claude.ai / ChatGPT clients without DCR (v0.41.3+)
|
||
|
||
The recommended hardening posture above is: ship `gbrain serve --http`
|
||
**without** `--enable-dcr` and pre-register every client manually. As of
|
||
v0.41.3, `gbrain auth register-client` accepts the OAuth fields
|
||
browser-based clients need:
|
||
|
||
```bash
|
||
# Pre-register claude.ai (confidential client; two redirect URIs)
|
||
gbrain auth register-client claude-ai \
|
||
--scopes "read write" \
|
||
--redirect-uri https://claude.ai/api/mcp/auth_callback \
|
||
--redirect-uri https://claude.com/api/mcp/auth_callback
|
||
# --grant-types is auto-set to authorization_code,refresh_token when
|
||
# --redirect-uri is passed; pass --grant-types explicitly to override.
|
||
|
||
# Pre-register ChatGPT (public PKCE client; no client_secret minted)
|
||
gbrain auth register-client chatgpt \
|
||
--scopes "read write" \
|
||
--redirect-uri https://chatgpt.com/connector/oauth/<HASH> \
|
||
--token-endpoint-auth-method none
|
||
```
|
||
|
||
Auth methods (`--token-endpoint-auth-method`):
|
||
|
||
- `client_secret_post` (default) — confidential client, secret in body
|
||
- `client_secret_basic` — confidential client, secret in `Authorization` header
|
||
- `none` — public PKCE-only client (no secret minted; ChatGPT custom
|
||
connector, Claude Code, Cursor)
|
||
|
||
The validator rejects unknown methods at the registration boundary, and
|
||
the same gate applies to the admin endpoint `POST /admin/api/register-client`
|
||
and the DCR `POST /register` path. Pre-v0.41.3 the CLI hard-coded
|
||
`redirect_uris = []` and `token_endpoint_auth_method = NULL`, forcing
|
||
operators to UPDATE `oauth_clients` rows by hand to make claude.ai work
|
||
without `--enable-dcr`. That footgun is gone.
|
||
|
||
### DCR consent default (v0.42.55+)
|
||
|
||
The "disable `client_credentials`, only allow `authorization_code`" guidance
|
||
above is now the built-in default for the DCR path, not just advice for custom
|
||
wrappers. With `--enable-dcr` on, a self-registered client defaults to the
|
||
`authorization_code` (browser-approval) grant, and an explicit
|
||
`client_credentials` request is rejected with `invalid_client_metadata`.
|
||
Operators who genuinely need the machine-to-machine grant on the registration
|
||
endpoint opt in with `--enable-dcr-insecure` (which implies `--enable-dcr`); a
|
||
startup WARNING prints whenever DCR is enabled, and a second when the insecure
|
||
grant is allowed. Pre-registering clients via the CLI / admin API is unchanged.
|
||
|
||
### 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 <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.
|
||
|
||
### Bind address (v0.34: loopback by default)
|
||
|
||
`gbrain serve --http` listens on `127.0.0.1` by default. Personal-laptop
|
||
installs cannot accidentally publish the brain to the LAN. Self-hosted
|
||
deployments that need remote access pass `--bind 0.0.0.0` (all
|
||
interfaces) or `--bind <interface-ip>` (specific NIC). A stderr WARN
|
||
fires when `--public-url` is set without `--bind` so the operator sees
|
||
the binding before the first request — common cause of "ngrok forwards
|
||
to me but the agent can't reach the upstream" misconfigurations.
|
||
|
||
### 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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
**v0.41.3:** the same allowlist now gates every OAuth endpoint (`/mcp`,
|
||
`/token`, `/authorize`, `/register`, `/revoke`). Pre-v0.41.3 these used
|
||
default-wide-open `cors()` middleware, leaking
|
||
`Access-Control-Allow-Origin: *` on every response — any web origin could
|
||
complete a token exchange from a logged-in operator's browser. The CORS
|
||
preflight handler in the legacy bearer transport was also asymmetric
|
||
(actual-request path correctly default-deny, but OPTIONS preflight leaked
|
||
`Access-Control-Allow-Methods` + `Access-Control-Allow-Headers` to every
|
||
Origin); both are now consolidated through a single allowlist-gated path.
|
||
A startup stderr WARN fires when `--bind 0.0.0.0` is set without
|
||
`GBRAIN_HTTP_CORS_ORIGIN`, surfacing the default-deny posture before the
|
||
first 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
|
||
|
||
**Loopback-only by default** (v0.41.3+ Express server agrees with the
|
||
legacy transport; pre-v0.41.3 the Express server hardcoded `'loopback'`
|
||
while docs claimed "disabled by default" — that disagreement is gone).
|
||
The default trusts only same-host proxies (127.0.0.1, ::1, fc00::/7);
|
||
external forwarded-for headers are ignored regardless. To widen or
|
||
narrow trust:
|
||
|
||
```bash
|
||
# Trust exactly one hop — Fly.io, Render, Vercel, single-layer nginx
|
||
GBRAIN_HTTP_TRUST_PROXY=1 gbrain serve --http --port 8787
|
||
|
||
# Trust N hops — Cloudflare → nginx → gbrain
|
||
GBRAIN_HTTP_TRUST_PROXY=2 gbrain serve --http --port 8787
|
||
|
||
# Disable entirely — direct-exposure deployment with no proxy
|
||
GBRAIN_HTTP_TRUST_PROXY=0 gbrain serve --http --port 8787
|
||
|
||
# Named Express modes (uniquelocal, linklocal) or CIDR lists pass through
|
||
GBRAIN_HTTP_TRUST_PROXY=uniquelocal gbrain serve --http --port 8787
|
||
GBRAIN_HTTP_TRUST_PROXY="10.0.0.0/8,192.168.1.0/24" gbrain serve --http --port 8787
|
||
```
|
||
|
||
Both transports (Express OAuth server in `src/commands/serve-http.ts` and
|
||
the legacy bearer transport in `src/mcp/http-transport.ts`) read the same
|
||
env var, so single source of truth.
|
||
|
||
**Critical safety contract:** only widen past `'loopback'` when **both**
|
||
of these are true:
|
||
|
||
1. gbrain is reachable only via a trusted reverse proxy (not directly
|
||
exposed to the internet on the configured port). As of v0.34
|
||
`gbrain serve --http` binds `127.0.0.1` by default, so the
|
||
reverse-proxy-only posture is the out-of-the-box shape; only
|
||
override with `--bind 0.0.0.0` (or a specific interface IP) when
|
||
gbrain itself needs to accept remote connections directly.
|
||
2. The proxy strips any client-supplied `X-Forwarded-For` and `X-Real-IP`
|
||
headers, then sets them itself. (nginx with `proxy_set_header
|
||
X-Forwarded-For $remote_addr` does this; Cloudflare and most cloud
|
||
load balancers handle it automatically.)
|
||
|
||
If gbrain is reachable directly AND `GBRAIN_HTTP_TRUST_PROXY=1` (or any
|
||
non-loopback value) is set, clients can spoof their IP by sending
|
||
arbitrary `X-Forwarded-For` headers, defeating the pre-auth IP rate
|
||
limit. The `'loopback'` default protects against this by ignoring all
|
||
forwarded-for headers and using the socket peer address.
|
||
|
||
### Body size cap
|
||
|
||
Default 1 MiB, stream-counted (chunked transfers without
|
||
`Content-Length` are still capped). Override:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```bash
|
||
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.
|
||
|
||
**v0.26.9 redaction default.** The `params` column now stores
|
||
`{redacted, kind, declared_keys, unknown_key_count, approx_bytes}` instead
|
||
of raw JSON-RPC payloads. Declared keys (intersected against the operation's
|
||
spec) preserve for debug visibility; unknown keys are counted but never
|
||
named so attackers can't probe key existence; byte sizes bucket to 1KB so
|
||
content sizes can't be binary-searched. The same shape is broadcast on the
|
||
admin SSE feed at `/admin/events`. Operators on a personal laptop who want
|
||
raw payloads back can pass `gbrain serve --http --log-full-params` (loud
|
||
stderr warning at startup). Multi-tenant deployments should leave it
|
||
on the redacted default.
|