mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-19 11:22:21 +02:00
fix(browse): CLI always sends explicit pair scopes via shared DEFAULT_PAIR_SCOPES
The effective pairing default lived in two places: the CLI omitted scopes unless --restrict was passed, and the server filled in its own literal. handlePairAgent now always sends an explicit scopes list and both sides reference one exported constant, DEFAULT_PAIR_SCOPES, so the default cannot silently drift again (pinned by a server-auth source tripwire). Three input traps closed in the same surface: - Bare --restrict (or --restrict swallowing the next flag) parsed as "no restriction" and silently granted FULL access, the opposite of the user's intent. validatePairAgentFlags rejects it pre-server, before any consent gate, so an arg error never boots a daemon. - A scopes list could smuggle the control scope past the explicit flag: --restrict "read,control" minted a control-scoped session with no --control. /pair now 400s on control in a scopes list without the control flag, and the CLI points the user at --control. - Option typos validated only at exchange time: createSetupKey stored any scope string and any rateLimit, so /pair returned 200 with a poisoned setup key whose failure surfaced to the REMOTE agent at /connect as a misleading "Invalid request body". Shared validation now runs in both creators and throws typed InvalidScopeError; /pair and /token 400 with the message, naming the bad scope or negative rateLimit. Also `opts.rateLimit || 10` became `?? 10` so the documented "0 = unlimited" survives the /pair path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f28bfd0158
commit
44e0b6f7be
+31
-8
@@ -31,7 +31,8 @@ import {
|
||||
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
|
||||
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
|
||||
listTokens, recordCommand,
|
||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||
isRootToken, checkConnectRateLimit, type TokenInfo, type ScopeCategory,
|
||||
DEFAULT_PAIR_SCOPES, InvalidScopeError,
|
||||
} from './token-registry';
|
||||
import { validateTempPath } from './path-security';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
||||
@@ -2316,7 +2317,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
scopes: session.scopes,
|
||||
agent: session.clientId,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Name the caller's typo (bad scope, negative rateLimit) instead of
|
||||
// hiding it behind the generic body error.
|
||||
if (err instanceof InvalidScopeError) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -2384,12 +2392,20 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
}
|
||||
try {
|
||||
const pairBody = await req.json() as any;
|
||||
// Default: full access (read+write+admin+meta). The trust boundary is
|
||||
// the pairing ceremony itself, not the scope. --control adds browser-wide
|
||||
// destructive commands (stop, restart, disconnect). --restrict limits scope.
|
||||
// Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary
|
||||
// is the pairing ceremony itself, not the scope. --control adds
|
||||
// browser-wide destructive commands (stop, restart, disconnect).
|
||||
// --restrict limits scope — but can never grant control: that scope
|
||||
// stays behind the explicit control flag.
|
||||
if (!pairBody.control && !pairBody.admin
|
||||
&& Array.isArray(pairBody.scopes) && pairBody.scopes.includes('control')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'The control scope requires the control flag (--control); it cannot be granted via a scopes list.',
|
||||
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const scopes = pairBody.control || pairBody.admin
|
||||
? ['read', 'write', 'admin', 'meta', 'control'] as const
|
||||
: (pairBody.scopes || ['read', 'write', 'admin', 'meta']) as const;
|
||||
? [...DEFAULT_PAIR_SCOPES, 'control' as const]
|
||||
: ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
|
||||
const setupKey = createSetupKey({
|
||||
clientId: pairBody.clientId,
|
||||
scopes: [...scopes],
|
||||
@@ -2425,7 +2441,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
tunnel_url: verifiedTunnelUrl,
|
||||
server_url: `http://127.0.0.1:${browsePort}`,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Name the caller's typo (bad scope, negative rateLimit) instead of
|
||||
// hiding it behind the generic body error.
|
||||
if (err instanceof InvalidScopeError) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user