mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 07:59:02 +02:00
v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot (#2665)
* fix(pairing): reject reserved clientId 'root' at all token writers 'root' is the sentinel checkScope/checkDomain/checkRate and the server command gate use for the omnipotent caller, so a scoped token carrying it bypasses every enforcement path. Add ReservedClientIdError + a shared assertValidClientId; createToken/createSetupKey throw, restoreRegistry skips-and-logs (a corrupt state file must not brick boot). /pair and /token surface it as a named 400, and the CLI fast-fails --client root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): release tab ownership on revoke tabOwnership cleared only on tab close, so after DELETE /token a same-name re-pair inherited the revoked agent's authenticated tabs (own-only access keys on owner === clientId). Add BrowserManager.releaseClientTabs and run it unconditionally in DELETE /token (ownership outlives the token, so an expired-token client can still own tabs); 404 only when both nothing was revoked and nothing released. Response now carries tabs_released. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot POST /pair minted a new setup key but never touched the agent's live session, so re-pairing --client X --restrict read while X was connected (or whose 5-min key expired unexchanged) left the original full-access session, eval included, alive up to 24h. A reducing re-pair (fewer scopes, tighter domains, lower rate, stricter tab policy) now revokes the live session and releases its tabs before minting the new key (grantReducesAccess + revokeClientFully; superseded in the response). Non-reducing re-pairs keep the session and only drop stale PENDING setup keys, so a broaden/refresh never strands a working agent and a narrowing re-pair issued before the agent connects can't leave the old broad key exchangeable. Revoke happens before mint (revokeToken deletes all of a client's tokens). CLI prints a version-skew-safe supersede notice and warns when a re-pair-shaped call omits --client. Docs + CHANGELOG + VERSION. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): harden re-pair per adversarial review Adversarial review of the diff found four issues, now fixed: - Validate the requested grant BEFORE the supersede revoke: a reducing re-pair with a bad scope/rate no longer destroys the live session and then fails to mint a replacement (assertValidTokenOptions runs up front). - A re-pair with no live session releases tabs orphaned by an expired incarnation, closing the tab-inheritance gap /pair had (DELETE /token already released unconditionally). - Test the DELETE /token revoked=0/tabs>0 path and the /pair orphaned-tab release at the handler level (HTTP e2e can't, headless owns no tabs). - Test the CLI --client root fast-fail; fix its null-guard (parseFlag returns null when --client is absent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garry@ycombinator.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Garry Tan
parent
51932eceef
commit
85fd9db554
+58
-10
@@ -32,7 +32,8 @@ import {
|
||||
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
|
||||
listTokens, recordCommand,
|
||||
isRootToken, checkConnectRateLimit, type TokenInfo, type ScopeCategory,
|
||||
DEFAULT_PAIR_SCOPES, InvalidScopeError,
|
||||
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError, assertValidClientId,
|
||||
assertValidTokenOptions, revokeSetupKeys, getClientSession, grantReducesAccess,
|
||||
} from './token-registry';
|
||||
import { validateTempPath } from './path-security';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
||||
@@ -2318,9 +2319,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
agent: session.clientId,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (err) {
|
||||
// Name the caller's typo (bad scope, negative rateLimit) instead of
|
||||
// hiding it behind the generic body error.
|
||||
if (err instanceof InvalidScopeError) {
|
||||
// Name the caller's typo (bad scope, negative rateLimit, reserved
|
||||
// clientId) instead of hiding it behind the generic body error.
|
||||
if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -2348,13 +2349,18 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
});
|
||||
}
|
||||
const revoked = revokeToken(clientId);
|
||||
if (!revoked) {
|
||||
// Release tabs UNCONDITIONALLY: ownership outlives the token (it clears
|
||||
// only on tab close), so a client whose token already expired can still
|
||||
// own tabs. Gating release on a revoke hit would orphan that ownership
|
||||
// and let a same-name re-pair inherit an authenticated tab.
|
||||
const tabsReleased = browserManager.releaseClientTabs(clientId).length;
|
||||
if (!revoked && tabsReleased === 0) {
|
||||
return new Response(JSON.stringify({ error: `Agent "${clientId}" not found` }), {
|
||||
status: 404, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
console.log(`[browse] Revoked ${revoked} token(s) for: ${clientId}`);
|
||||
return new Response(JSON.stringify({ revoked: clientId, tokens_deleted: revoked }), {
|
||||
console.log(`[browse] Revoked ${revoked} token(s), released ${tabsReleased} tab(s) for: ${clientId}`);
|
||||
return new Response(JSON.stringify({ revoked: clientId, tokens_deleted: revoked, tabs_released: tabsReleased }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -2392,6 +2398,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
}
|
||||
try {
|
||||
const pairBody = await req.json() as any;
|
||||
// Reject a reserved/invalid clientId up front (createSetupKey enforces
|
||||
// it too, but this makes the 400 unambiguous and skips the teardown).
|
||||
if (pairBody.clientId !== undefined) assertValidClientId(pairBody.clientId);
|
||||
// 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).
|
||||
@@ -2406,6 +2415,44 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
const scopes = pairBody.control || pairBody.admin
|
||||
? [...DEFAULT_PAIR_SCOPES, 'control' as const]
|
||||
: ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
|
||||
// D1: a re-pair supersedes prior grants. ALWAYS drop stale setup keys
|
||||
// so a superseded broad key can never be exchanged — this closes the
|
||||
// shadow-key hole where a narrowing re-pair before the agent connects
|
||||
// would otherwise leave the old broad key live. Revoke the live
|
||||
// SESSION only when the new grant actually reduces access, so a
|
||||
// broaden/refresh never strands a working agent mid-task. Compare
|
||||
// against the resolved grant (not raw pairBody) so dropping 'control'
|
||||
// or a default re-pair is classified correctly. Revoke runs BEFORE
|
||||
// createSetupKey — revokeToken deletes all of a clientId's tokens, so
|
||||
// minting first would nuke the fresh key.
|
||||
const grant = {
|
||||
scopes: [...scopes] as ScopeCategory[],
|
||||
domains: pairBody.domains as string[] | undefined,
|
||||
rateLimit: pairBody.rateLimit ?? 10,
|
||||
tabPolicy: 'own-only' as const,
|
||||
};
|
||||
// Validate BEFORE any revoke (createSetupKey validates too, but that
|
||||
// runs after the teardown below). A bad scope or negative rateLimit
|
||||
// must 400 without knocking a live session offline — otherwise a
|
||||
// reducing re-pair with a typo (--restrict red) destroys the session
|
||||
// and mints no replacement.
|
||||
assertValidTokenOptions(grant.scopes, grant.rateLimit);
|
||||
const priorSession = pairBody.clientId ? getClientSession(pairBody.clientId) : null;
|
||||
let superseded: { tokens_deleted: number; tabs_released: number } | undefined;
|
||||
if (priorSession && grantReducesAccess(priorSession, grant)) {
|
||||
const tokensDeleted = revokeToken(pairBody.clientId);
|
||||
const tabsReleased = browserManager.releaseClientTabs(pairBody.clientId).length;
|
||||
superseded = { tokens_deleted: tokensDeleted, tabs_released: tabsReleased };
|
||||
console.log(`[browse] Superseded ${tokensDeleted} token(s), released ${tabsReleased} tab(s) for reducing re-pair: ${pairBody.clientId}`);
|
||||
} else if (pairBody.clientId) {
|
||||
revokeSetupKeys(pairBody.clientId);
|
||||
// No live session, but tab ownership outlives token expiry: free any
|
||||
// tabs orphaned by an expired session so this re-pair can't inherit
|
||||
// an earlier incarnation's authenticated pages (mirrors DELETE
|
||||
// /token's unconditional release). A live-session broaden keeps its
|
||||
// tabs — the working agent still owns them.
|
||||
if (!priorSession) browserManager.releaseClientTabs(pairBody.clientId);
|
||||
}
|
||||
const setupKey = createSetupKey({
|
||||
clientId: pairBody.clientId,
|
||||
scopes: [...scopes],
|
||||
@@ -2440,11 +2487,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
scopes: setupKey.scopes,
|
||||
tunnel_url: verifiedTunnelUrl,
|
||||
server_url: `http://127.0.0.1:${browsePort}`,
|
||||
...(superseded ? { superseded } : {}),
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (err) {
|
||||
// Name the caller's typo (bad scope, negative rateLimit) instead of
|
||||
// hiding it behind the generic body error.
|
||||
if (err instanceof InvalidScopeError) {
|
||||
// Name the caller's typo (bad scope, negative rateLimit, reserved
|
||||
// clientId) instead of hiding it behind the generic body error.
|
||||
if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user