fix: CSO security fixes — token leak, domain bypass, input validation

1. Remove root token from /health endpoint entirely (CSO #1 CRITICAL).
   Origin header is spoofable. Extension reads from ~/.gstack/.auth.json.
2. Add domain check for newtab URL (CSO #5). Previously only goto was
   checked, allowing domain-restricted agents to bypass via newtab.
3. Validate scope values, rateLimit, expiresSeconds in createToken()
   (CSO #4). Rejects invalid scopes and negative values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-04 23:37:36 -07:00
parent bda0cfda1e
commit cd85bdc196
4 changed files with 100 additions and 13 deletions
+14 -5
View File
@@ -893,6 +893,16 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
// ─── newtab with ownership for scoped tokens ──────────────
if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') {
// Domain check for newtab URL (same as goto)
if (args[0] && !checkDomain(tokenInfo, args[0])) {
return new Response(JSON.stringify({
error: 'Domain not allowed by your token scope',
hint: `Allowed domains: ${tokenInfo.domains?.join(', ') || 'none configured'}`,
}), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId);
return new Response(JSON.stringify({
tabId: newId,
@@ -1201,11 +1211,10 @@ async function start() {
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
// Auth token for extension bootstrap. Only returned when the request
// comes from a Chrome extension (Origin: chrome-extension://...).
// Previously served unconditionally, but that leaks the token if the
// server is tunneled to the internet (ngrok, SSH tunnel).
...(req.headers.get('origin')?.startsWith('chrome-extension://') ? { token: AUTH_TOKEN } : {}),
// Auth token NOT served here. Extension reads from ~/.gstack/.auth.json
// (written by launchHeaded at browser-manager.ts:243). Serving the token
// on an unauthenticated endpoint is unsafe because Origin headers are
// trivially spoofable, and ngrok exposes /health to the internet.
chatEnabled: true,
agent: {
status: agentStatus,
+12
View File
@@ -169,6 +169,18 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
expiresSeconds = 86400, // 24h default
} = opts;
// Validate inputs
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta'];
for (const s of scopes) {
if (!validScopes.includes(s as ScopeCategory)) {
throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
}
}
if (rateLimit < 0) throw new Error('rateLimit must be >= 0');
if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
throw new Error('expiresSeconds must be >= 0 or null');
}
const token = generateToken('gsk_sess_');
const now = new Date();
const expiresAt = expiresSeconds === null