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>
119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
/**
|
|
* Tests for parseCorsAllowlistOAuth() and resolveCorsOrigin() in
|
|
* src/commands/serve-http.ts.
|
|
*
|
|
* v0.41.3 (T7): pre-fix every OAuth endpoint (/mcp, /token, /authorize,
|
|
* /register, /revoke) used bare `cors()` which defaults to
|
|
* Access-Control-Allow-Origin: * — any web origin could complete a token
|
|
* exchange from a logged-in operator's browser. The fix gates every OAuth
|
|
* surface behind GBRAIN_HTTP_CORS_ORIGIN with default-deny.
|
|
*
|
|
* Two pure functions, no Express integration needed for the unit shape.
|
|
* The end-to-end Express-router behavior (cors middleware + browser
|
|
* preflight) is verified by test/e2e/serve-http-oauth.test.ts.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { parseCorsAllowlistOAuth, resolveCorsOrigin } from '../src/commands/serve-http.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
describe('parseCorsAllowlistOAuth', () => {
|
|
test('unset → null (default-deny posture)', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: undefined }, async () => {
|
|
expect(parseCorsAllowlistOAuth()).toBeNull();
|
|
});
|
|
});
|
|
|
|
test('empty string → null', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: '' }, async () => {
|
|
expect(parseCorsAllowlistOAuth()).toBeNull();
|
|
});
|
|
});
|
|
|
|
test('whitespace-only → null (no usable origins)', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: ' , ,' }, async () => {
|
|
expect(parseCorsAllowlistOAuth()).toBeNull();
|
|
});
|
|
});
|
|
|
|
test('single origin → Set of one', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: 'https://claude.ai' }, async () => {
|
|
const set = parseCorsAllowlistOAuth();
|
|
expect(set).not.toBeNull();
|
|
expect(set!.size).toBe(1);
|
|
expect(set!.has('https://claude.ai')).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('comma-separated origins → Set of N', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: 'https://claude.ai,https://chatgpt.com,https://my.app' }, async () => {
|
|
const set = parseCorsAllowlistOAuth();
|
|
expect(set!.size).toBe(3);
|
|
expect(set!.has('https://claude.ai')).toBe(true);
|
|
expect(set!.has('https://chatgpt.com')).toBe(true);
|
|
expect(set!.has('https://my.app')).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('whitespace around values is trimmed', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: ' https://a.app , https://b.app ' }, async () => {
|
|
const set = parseCorsAllowlistOAuth();
|
|
expect(set!.has('https://a.app')).toBe(true);
|
|
expect(set!.has('https://b.app')).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('case-sensitive match (Origin headers are case-sensitive per RFC 6454)', async () => {
|
|
await withEnv({ GBRAIN_HTTP_CORS_ORIGIN: 'https://Claude.AI' }, async () => {
|
|
const set = parseCorsAllowlistOAuth();
|
|
expect(set!.has('https://Claude.AI')).toBe(true);
|
|
expect(set!.has('https://claude.ai')).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveCorsOrigin', () => {
|
|
test('null allowlist → false (cors middleware sends no Allow-Origin)', () => {
|
|
expect(resolveCorsOrigin(null)).toBe(false);
|
|
});
|
|
|
|
test('allowlist + missing Origin → cb(null, true) (same-origin requests aren\'t cross-origin)', () => {
|
|
const fn = resolveCorsOrigin(new Set(['https://claude.ai']));
|
|
expect(typeof fn).toBe('function');
|
|
const calls: Array<{err: Error | null; allow?: boolean}> = [];
|
|
(fn as Function)(undefined, (err: Error | null, allow?: boolean) => calls.push({err, allow}));
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0].err).toBeNull();
|
|
expect(calls[0].allow).toBe(true);
|
|
});
|
|
|
|
test('allowlist + matching Origin → cb(null, true)', () => {
|
|
const fn = resolveCorsOrigin(new Set(['https://claude.ai']));
|
|
const calls: Array<{err: Error | null; allow?: boolean}> = [];
|
|
(fn as Function)('https://claude.ai', (err: Error | null, allow?: boolean) => calls.push({err, allow}));
|
|
expect(calls[0].allow).toBe(true);
|
|
});
|
|
|
|
test('allowlist + NON-matching Origin → cb(null, false) — the regression', () => {
|
|
const fn = resolveCorsOrigin(new Set(['https://claude.ai']));
|
|
const calls: Array<{err: Error | null; allow?: boolean}> = [];
|
|
(fn as Function)('https://evil.example', (err: Error | null, allow?: boolean) => calls.push({err, allow}));
|
|
expect(calls[0].err).toBeNull();
|
|
expect(calls[0].allow).toBe(false);
|
|
});
|
|
|
|
test('multi-origin allowlist + match → true', () => {
|
|
const fn = resolveCorsOrigin(new Set(['https://claude.ai', 'https://chatgpt.com']));
|
|
const calls: Array<boolean | undefined> = [];
|
|
(fn as Function)('https://chatgpt.com', (_err: unknown, allow?: boolean) => calls.push(allow));
|
|
expect(calls[0]).toBe(true);
|
|
});
|
|
|
|
test('case-sensitive — "https://Claude.AI" does NOT match "https://claude.ai"', () => {
|
|
const fn = resolveCorsOrigin(new Set(['https://claude.ai']));
|
|
const calls: Array<boolean | undefined> = [];
|
|
(fn as Function)('https://Claude.AI', (_err: unknown, allow?: boolean) => calls.push(allow));
|
|
expect(calls[0]).toBe(false);
|
|
});
|
|
});
|