From ff0d2cb2d92e3fa639fdb26a1a819e3dda7f5b06 Mon Sep 17 00:00:00 2001 From: Clevin Canales Date: Fri, 8 May 2026 20:12:08 -0400 Subject: [PATCH 1/3] fix(serve-http): configurable trust proxy via GBRAIN_TRUST_PROXY env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `trust proxy: 'loopback'` only trusts 127.0.0.1/::1, so PaaS proxies (Fly.io, Render, Heroku, Railway) whose internal hops are on private RFC-1918 addresses are never trusted. Every external client then appears to share the same IP (the PaaS internal hop's address), which makes ccRateLimiter, adminAuthRateLimiter, and registerRateLimiter (added in the next commit) share a single rate-limit bucket across all clients — effectively bypassing them. Default changes to `1` (trust exactly one upstream hop), the canonical Fly.io / standard PaaS pattern. Self-hosted operators who sit behind no proxy should set GBRAIN_TRUST_PROXY=false; multi-hop CDN setups can set 2 or 3. Ref: https://expressjs.com/en/guide/behind-proxies.html Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/serve-http.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 25d1f6148..6258b8d6c 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -211,7 +211,32 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // Express 5 app const app = express(); - app.set('trust proxy', 'loopback'); // Caddy/Tailscale reverse proxy on localhost + + // --------------------------------------------------------------------------- + // Proxy trust configuration + // + // Default: trust exactly one upstream hop (the PaaS edge proxy on Fly.io, + // Render, Heroku, Railway, etc.). This makes req.ip resolve to the real + // client IP so downstream rate limiters (ccRateLimiter, adminAuthRateLimiter, + // registerRateLimiter) work per-client instead of seeing every request from + // the same PaaS-internal address. + // + // GBRAIN_TRUST_PROXY values: + // "1" (default) — trust one upstream hop (Fly.io / standard PaaS) + // "2", "3", ... — trust N hops (multi-layer PaaS/CDN setups) + // "false" — no proxy; req.ip is the TCP peer (self-hosted, no proxy) + // "true" — trust all hops (only for fully controlled infra) + // loopback-only deployments (Caddy/Tailscale on localhost) should use "false" + // + // See https://expressjs.com/en/guide/behind-proxies.html + // --------------------------------------------------------------------------- + const trustProxy = process.env.GBRAIN_TRUST_PROXY ?? '1'; + const tpValue: boolean | number | string = + trustProxy === 'true' ? true + : trustProxy === 'false' ? false + : /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) + : trustProxy; + app.set('trust proxy', tpValue); // --------------------------------------------------------------------------- // Cookie parsing — required for /admin auth (express 5 has no built-in) From ab6900bb4643fddd86255a6d86b5f362054110d2 Mon Sep 17 00:00:00 2001 From: Clevin Canales Date: Fri, 8 May 2026 20:12:25 -0400 Subject: [PATCH 2/3] fix(serve-http): rate-limit DCR /register to prevent pool exhaustion When --enable-dcr is active, the SDK's mcpAuthRouter mounts /register with no rate limiter. Combined with the open CORS policy on /register, any internet client can flood it. Each registration hashes a secret, INSERTs into oauth_clients, and writes to the database. At typical Supabase transaction-pooler sizes a sustained flood exhausts the pool and causes 503s for all other requests. Adds a 10-req/min/IP limiter scoped to /register, inserted before app.use(authRouter) so it fires regardless of SDK routing internals. The limiter is a no-op when --enable-dcr is not passed (SDK never mounts /register). Depends on the trust-proxy fix in the previous commit: without correct client IPs, all registrations share one rate-limit bucket and the limiter is trivially bypassed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/serve-http.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 6258b8d6c..4cddb8e9a 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -354,6 +354,31 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption next(); }); + // --------------------------------------------------------------------------- + // DCR (Dynamic Client Registration) rate limiter + // + // /register is mounted by mcpAuthRouter when --enable-dcr is on. Without a + // limiter, any internet client can flood it: each registration hashes a + // secret, INSERTs into oauth_clients, and writes to the database pool. At + // Supabase transaction-pooler sizes a sustained flood exhausts the pool, + // causing 503s for all other requests. + // + // Placed before app.use(authRouter) so it fires regardless of SDK internals. + // Only meaningful when --enable-dcr is passed; no-ops otherwise (the SDK + // never mounts /register so traffic never reaches this middleware). + // + // Depends on Issue 1 (trust proxy) being correct: without real client IPs, + // all registrations share one bucket and the limiter is trivially bypassed. + // --------------------------------------------------------------------------- + const registerRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 10, // 10 registrations per minute per IP + standardHeaders: true, + legacyHeaders: false, + message: { error: 'too_many_requests', error_description: 'Too many client registrations from this IP. Try again later.' }, + }); + + app.use('/register', registerRateLimiter); app.use(authRouter); // --------------------------------------------------------------------------- From 183dd711584d708fe0989a639c8c02710783750f Mon Sep 17 00:00:00 2001 From: Clevin Canales Date: Fri, 8 May 2026 20:12:43 -0400 Subject: [PATCH 3/3] docs(deploy): document GBRAIN_DISABLE_DIRECT_POOL for Supabase deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.30.1's dual-pool routing tries to connect to the Postgres direct host (only reachable inside Supabase's VPC). External deployments (Fly.io, Render, Railway, VPS) time out or fail at startup with no clear error. The fix — GBRAIN_DISABLE_DIRECT_POOL=1 + GBRAIN_POOL_SIZE=1 with the transaction pooler URL on port 6543 — is undocumented and counterintuitive. Every Supabase user deploying externally hits this. Adds a "Supabase Deployment Caveat" section to docs/mcp/DEPLOY.md with: - required env vars and why - correct connection string (port 6543, not 5432) - minimal Fly.io fly.toml env block showing GBRAIN_TRUST_PROXY=1 alongside Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/mcp/DEPLOY.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/mcp/DEPLOY.md b/docs/mcp/DEPLOY.md index 4f508bf35..f280a8766 100644 --- a/docs/mcp/DEPLOY.md +++ b/docs/mcp/DEPLOY.md @@ -216,6 +216,52 @@ paths outside cwd are rejected. Page slugs and filenames are allowlist-validated CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since the user owns the machine. +## Supabase Deployment Caveat + +If your brain is backed by Supabase and you are deploying `gbrain serve --http` +to an external host (Fly.io, Render, Railway, your own VPS, etc.), you must +set two environment variables: + +```bash +GBRAIN_DISABLE_DIRECT_POOL=1 +GBRAIN_POOL_SIZE=1 +``` + +**Why this is needed:** + +GBrain's dual-pool routing tries to open a second direct connection to the +Postgres host (port 5432) alongside the standard pooler connection. On +Supabase that direct host is only reachable from inside Supabase's VPC — it +is not accessible from external deployments. Without `GBRAIN_DISABLE_DIRECT_POOL=1` +the startup sequence attempts an IPv6 connection to the direct host, times out +(or receives a connection-refused), and either crashes or falls back to a +degraded state. + +**Connection string:** + +Use the **Transaction Pooler** URL (port **6543**, not 5432): + +``` +postgresql://postgres.:@aws-0-.pooler.supabase.com:6543/ +``` + +The Session Pooler (port 5432 on the pooler host) also works but has higher +per-connection overhead. The **direct** connection string (`db..supabase.co:5432`) +will not work from outside the Supabase VPC and should not be used for +externally-deployed servers. + +**Minimal Fly.io `fly.toml` env block example:** + +```toml +[env] + GBRAIN_DISABLE_DIRECT_POOL = "1" + GBRAIN_POOL_SIZE = "1" + GBRAIN_TRUST_PROXY = "1" +``` + +Set `DATABASE_URL` as a secret (`fly secrets set DATABASE_URL=...`) — never +in `fly.toml`. + ## Deployment Options See [ALTERNATIVES.md](ALTERNATIVES.md) for a comparison of ngrok, Tailscale