mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
fix(browse): lock local auth to trusted extension
This commit is contained in:
+19
-19
@@ -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,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": {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user