fix(browse): lock local auth to trusted extension

This commit is contained in:
Sina
2026-07-10 12:48:25 -07:00
parent 7c9df1c568
commit 7b3f391bbc
24 changed files with 925 additions and 147 deletions
+19 -19
View File
@@ -33,43 +33,41 @@ function getBaseUrl() {
// ─── Auth Token Bootstrap ─────────────────────────────────────
async function loadAuthToken() {
if (authToken) return;
// Get token from browse server /health endpoint (localhost-only, safe).
// Previously read from .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning.
const base = getBaseUrl();
if (!base) return;
try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const data = await resp.json();
if (data.token) authToken = data.token;
}
// Session storage is restricted to trusted extension contexts. Content
// scripts run in untrusted contexts and cannot read the root bearer even
// though they legitimately use chrome.storage.local for non-secret state.
await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
const session = await chrome.storage.session.get('gstackAuthToken');
const local = await chrome.storage.local.get('port');
if (session.gstackAuthToken) authToken = session.gstackAuthToken;
if (local.port) serverPort = local.port;
// Clean up credentials written by prerelease builds of this migration.
await chrome.storage.local.remove('gstackAuthToken');
} catch (err) {
console.error('[gstack bg] Failed to load auth token:', err.message);
console.error('[gstack bg] Failed to load auth token from trusted extension storage:', err.message);
}
}
// ─── Health Polling ────────────────────────────────────────────
async function checkHealth() {
// Refresh before choosing the target: BrowserManager writes a new token and
// port whenever the daemon restarts.
await loadAuthToken();
const base = getBaseUrl();
if (!base) {
setDisconnected();
return;
}
// Retry loading auth token if we don't have one yet
if (!authToken) await loadAuthToken();
try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setDisconnected(); return; }
const data = await resp.json();
if (data.status === 'healthy') {
// Always refresh auth token from /health — the server generates a new
// token on each restart, so the old one becomes stale.
if (data.token) authToken = data.token;
// /health is intentionally status-only. Auth is delivered through the
// extension's isolated storage, not a caller-controlled HTTP request.
// Forward chatEnabled so sidepanel can show/hide chat tab
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
} else {
@@ -299,7 +297,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === 'getPort') {
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
// Content scripts can query the port for UI behavior. Never include a
// credential here; only extension pages may request it via getToken.
sendResponse({ port: serverPort, connected: isConnected });
return true;
}
+2
View File
@@ -2,7 +2,9 @@
"manifest_version": 3,
"name": "gstack browse",
"version": "0.1.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoXbtKYtvKA+STN/yRtOWb5ibKZXhHyTtiITl6K1qquF0g4Sd0zFO4LwmkGdzBCTUBLyVESeqjCLpP0WGJ0MS/g/YLMtmzLXWa2Bf/TNvrKIG/TxCfXLWDvdKayhS+lugLUX+tbrhRQiJ96Zoe2cFI0EXHa62mcLL9sNAwqmYNJAXK3uCJlveNa0NpeD8Jjmt3l2S9sjypVvz9JZmHnsgZbbK18VM3V65GpBs2BB/gzS8UDQbvR1vcrA5o9FHjgF0+NLrryNfyCJjm3YAHYJFKVda7uSHr4FlUZXl+IL9YynqODgx4Pp4kiY7bFCI7l9zzJIUkWnorskwzD3ziNUbJQIDAQAB",
"description": "Live activity feed and @ref overlays for gstack browse",
"externally_connectable": { "ids": [] },
"permissions": ["sidePanel", "storage", "activeTab", "scripting", "tabs"],
"host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"],
"action": {
+3 -3
View File
@@ -305,7 +305,7 @@
setState(STATE.LIVE);
ensureXterm();
nextBinaryIsReplay = false;
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [attachToken]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
@@ -599,7 +599,7 @@
// SameSite=Strict don't survive the jump from server.ts:34567 to the
// agent's random port from a chrome-extension origin, so cookies
// alone weren't reliable.
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [attachToken]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
@@ -821,7 +821,7 @@
setState(STATE.LIVE);
ensureXterm();
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${token}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [token]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
+13 -7
View File
@@ -1281,13 +1281,19 @@ async function tryConnect() {
const port = resp.port || 34567;
// Step 2: If background says connected + has token, use that
if (resp.port && resp.connected && resp.token) {
// Step 2: Background owns the port; request the token through the separate
// extension-page-only channel. getPort intentionally never returns it.
const tokenResp = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'getToken' }, (r) => {
resolve(r || {});
});
});
if (resp.port && resp.connected && tokenResp.token) {
setLoadingStatus(
`Server found on port ${port}, connecting...`,
`token: yes\nStarting SSE + chat polling...`
);
updateConnection(`http://127.0.0.1:${port}`, resp.token);
updateConnection(`http://127.0.0.1:${port}`, tokenResp.token);
return;
}
@@ -1304,12 +1310,12 @@ async function tryConnect() {
});
if (healthResp.ok) {
const data = await healthResp.json();
if (data.status === 'healthy' && data.token) {
if (data.status === 'healthy' && tokenResp.token) {
setLoadingStatus(
`Server healthy on port ${port}, connecting...`,
`token: yes (from /health)\nStarting SSE + activity feed...`
`token: yes (from extension storage)\nStarting SSE + activity feed...`
);
updateConnection(`http://127.0.0.1:${port}`, data.token);
updateConnection(`http://127.0.0.1:${port}`, tokenResp.token);
// The SEC shield used to drive off /health.security via the chat
// path's classifier; with the chat path ripped, the indicator is
// not driven yet. Leaving the shield element hidden by default.
@@ -1317,7 +1323,7 @@ async function tryConnect() {
}
setLoadingStatus(
`Server responded but not healthy (attempt ${connectAttempts})`,
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
`status: ${data.status}\ntoken: ${tokenResp.token ? 'yes' : 'no'}`
);
} else {
setLoadingStatus(