mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* v0.41.3.0 fix(security/mcp): OAuth CORS lockdown, pre-register without DCR, validator surface
Three expanded cherry-picks plus codex-surfaced live-CORS fix, parser
rewrite, atomicity fix, DCR validator gate, SECURITY.md reconciliation.
What ships
- gbrain auth register-client gets --redirect-uri (repeatable) and
--token-endpoint-auth-method flags so the SECURITY.md-recommended
"pre-register without --enable-dcr" path actually works for claude.ai
and ChatGPT custom connectors.
- ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = {client_secret_post,
client_secret_basic, none} validator gates all three registration
entry points (CLI, admin endpoint, DCR /register) so --enable-dcr is
no longer the looser path.
- Live Express OAuth server (/mcp, /token, /authorize, /register,
/revoke) was using default-wide-open cors() middleware — every
origin could complete a token exchange from a logged-in operator's
browser. Now default-deny; allowlist via GBRAIN_HTTP_CORS_ORIGIN.
- GBRAIN_HTTP_TRUST_PROXY env var on Express server with the same
semantics as the legacy bearer transport already had. Default
'loopback' preserved. SECURITY.md doc rewritten to match reality
(was lying that trust proxy was "disabled by default" while code
hardcoded 'loopback').
- Admin endpoint registration now atomic — INSERT-then-UPDATE for
public clients replaced with single INSERT via the new
registerClientManual(..., tokenEndpointAuthMethod) parameter (codex
outside-voice F4 catch).
- Legacy transport corsHeaders + corsPreflightHeaders consolidated
into one function gated on the allowlist for BOTH Allow-Origin and
Allow-Methods/Headers (codex F1; #983 thematically).
Surfaced by D7 codex outside-voice review on the v0.41.3 plan:
F1 (live Express CORS wide-open), F2 (indexOf parser couldn't do
repeatable flags), F3 (client_secret_basic missing from validator),
F4 (admin endpoint INSERT-then-UPDATE atomicity), F5 (DCR path
bypassed validator), F6 (env var already existed on legacy transport),
F7 (SECURITY.md vs impl doc disagreement).
Tests: 183 directly-touched cases green. Three new test files
(test/serve-http-trust-proxy.test.ts, test/serve-http-cors.test.ts,
test/auth-register-client-args.test.ts) + 18 new oauth.test.ts cases
+ 4 IRON RULE CORS preflight regressions.
Plan: ~/.claude/plans/system-instruction-you-are-working-wise-piglet.md
(D1-D11 captured, codex outside-voice integrated, GSTACK REVIEW REPORT
verdict CLEARED).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): audit-writer readRecent calendar-boundary flake
writer.log() uses real `new Date()` for filename computation, but the
test mocked `now` to 2026-05-22. When CI runs on a date in a different
ISO week (e.g. 2026-05-25 W22 vs the mocked W21), log() writes to one
file but readRecent(now) reads a different one — zero events overlap,
expect(2).toBe(0) fails.
Fix: write events directly to the file matching the test's mocked
`now` via writer.computeFilename(now), same pattern the cross-week
straddle test (line 234+) already used for the previous-week event.
Pre-existing test bug, surfaced when CI rolled past the week boundary
the original author wrote against. Not introduced by v0.41.3.0; fix
included here because /ship found it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
84 lines
3.0 KiB
TypeScript
84 lines
3.0 KiB
TypeScript
/**
|
|
* Tests for resolveTrustProxy() in src/commands/serve-http.ts.
|
|
*
|
|
* v0.41.3 (T8): GBRAIN_HTTP_TRUST_PROXY env var replaces the pre-fix hardcoded
|
|
* `app.set('trust proxy', 'loopback')`. The Express trust-proxy value
|
|
* determines whether X-Forwarded-For is honored (rate limit IP correctness)
|
|
* and whether req.secure detects HTTPS termination at a proxy.
|
|
*
|
|
* Pure function — no Express, no fetch, no env mutation. Each case calls
|
|
* resolveTrustProxy directly with the env string it would have read.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { resolveTrustProxy } from '../src/commands/serve-http.ts';
|
|
|
|
describe('resolveTrustProxy', () => {
|
|
test('unset → "loopback" (pre-v0.41.3 default)', () => {
|
|
expect(resolveTrustProxy(undefined)).toBe('loopback');
|
|
});
|
|
|
|
test('empty string → "loopback" (env was set but blank, treat as unset)', () => {
|
|
expect(resolveTrustProxy('')).toBe('loopback');
|
|
});
|
|
|
|
test('"0" → false (trust nothing — defeat X-Forwarded-For spoofing)', () => {
|
|
expect(resolveTrustProxy('0')).toBe(false);
|
|
});
|
|
|
|
test('"false" → false', () => {
|
|
expect(resolveTrustProxy('false')).toBe(false);
|
|
});
|
|
|
|
test('"1" → 1 (trust exactly one hop — Fly.io / Render / single-layer proxy)', () => {
|
|
expect(resolveTrustProxy('1')).toBe(1);
|
|
});
|
|
|
|
test('"true" → 1', () => {
|
|
expect(resolveTrustProxy('true')).toBe(1);
|
|
});
|
|
|
|
test('"2" → 2 (trust two hops — Cloudflare → nginx → gbrain)', () => {
|
|
expect(resolveTrustProxy('2')).toBe(2);
|
|
});
|
|
|
|
test('"10" → 10 (deep proxy chain)', () => {
|
|
expect(resolveTrustProxy('10')).toBe(10);
|
|
});
|
|
|
|
test('"loopback" → "loopback" (explicit pass-through)', () => {
|
|
expect(resolveTrustProxy('loopback')).toBe('loopback');
|
|
});
|
|
|
|
test('"uniquelocal" → "uniquelocal" (Express named mode)', () => {
|
|
expect(resolveTrustProxy('uniquelocal')).toBe('uniquelocal');
|
|
});
|
|
|
|
test('"linklocal" → "linklocal" (Express named mode)', () => {
|
|
expect(resolveTrustProxy('linklocal')).toBe('linklocal');
|
|
});
|
|
|
|
test('CIDR list passes through verbatim (Express parses it)', () => {
|
|
expect(resolveTrustProxy('10.0.0.0/8,192.168.1.0/24')).toBe('10.0.0.0/8,192.168.1.0/24');
|
|
});
|
|
|
|
test('garbage string passes through (Express will reject at startup if invalid)', () => {
|
|
// Fail-loud strategy: don't silently fall back to a default on garbage.
|
|
// Express's IP filter will throw at boot, surfacing the typo immediately
|
|
// rather than silently producing an unexpected security posture.
|
|
expect(resolveTrustProxy('frobnicate')).toBe('frobnicate');
|
|
});
|
|
|
|
test('numeric string with leading zero ("007") parses as 7', () => {
|
|
// /^\d+$/ matches; parseInt accepts.
|
|
expect(resolveTrustProxy('007')).toBe(7);
|
|
});
|
|
|
|
test('"-1" passes through as string (not numeric — Express rejects)', () => {
|
|
// The /^\d+$/ regex deliberately excludes negative numbers; pass-through
|
|
// means Express sees an invalid value and throws at boot rather than
|
|
// silently treating it as 1 or false.
|
|
expect(resolveTrustProxy('-1')).toBe('-1');
|
|
});
|
|
});
|