fix(browse): extension token bootstrap moves to pinned-origin POST; /health carries no token

GET /health is now liveness/status only in every mode — both token
carve-outs (headed-mode disjunct AND chrome-extension:// Origin
disjunct) are removed. Token bootstrap is POST /extension-token on the
local listener: the Origin header must be exactly
chrome-extension://<GSTACK_EXTENSION_ID> and the Host header's hostname
must parse to 127.0.0.1 or localhost (parsed via new URL, never literal
equality — Host arrives as '127.0.0.1:34567'). Wrong origin/host → 403
with no detail. The tunnel surface 404s the endpoint (not in
TUNNEL_PATHS, verified by test).

The extension ID is pinned by a new "key" field (RSA public key) in
extension/manifest.json; browse/scripts/extension-id.ts reproduces the
ID derivation (first 16 bytes of SHA-256 of the DER public key, hex
mapped 0-9a-f → a-p). The private key is not committed anywhere —
unpacked/baked-in loads only need the public key.

Extension side: background.js bootstraps and refreshes the token via
POST /extension-token (403 → disconnected state); sidepanel.js direct
connect path does the same; sidepanel-terminal.js's dead /health token
fallback (read AUTH_TOKEN/authToken keys the server never sent,
hardcoded port) is replaced with the window.gstackAuthToken path.

MIGRATION NOTE: the manifest key pins the extension ID, so existing
installs' side-panel local state (saved port, snoozes) resets once —
explained in-product via a one-time notice (flag
gstack_id_migrated_v162). After upgrading the server, restart the
browser so the old service worker stops polling for a token GET /health
no longer serves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e9a0b6847a2d17fe6656a4686b4efd0c8380eb09)
This commit is contained in:
Garry Tan
2026-08-12 15:31:49 -07:00
parent 2ae38785a1
commit a2751b7cf2
14 changed files with 471 additions and 94 deletions
+44 -12
View File
@@ -1304,21 +1304,36 @@ async function tryConnect() {
});
if (healthResp.ok) {
const data = await healthResp.json();
if (data.status === 'healthy' && data.token) {
if (data.status === 'healthy') {
// /health is liveness-only — the token comes from the pinned-origin
// POST /extension-token bootstrap (our chrome-extension:// Origin
// is validated server-side against the manifest-pinned ID).
const tokenResp = await fetch(`http://127.0.0.1:${port}/extension-token`, {
method: 'POST',
signal: AbortSignal.timeout(2000),
});
const tokenData = tokenResp.ok ? await tokenResp.json() : null;
if (tokenData?.token) {
setLoadingStatus(
`Server healthy on port ${port}, connecting...`,
`token: yes (from /extension-token)\nStarting SSE + activity feed...`
);
updateConnection(`http://127.0.0.1:${port}`, tokenData.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.
return;
}
setLoadingStatus(
`Server healthy on port ${port}, connecting...`,
`token: yes (from /health)\nStarting SSE + activity feed...`
`Server healthy but token bootstrap failed (attempt ${connectAttempts})`,
`POST /extension-token → ${tokenResp.status}${tokenResp.status === 403 ? ' (extension identity not trusted)' : ''}`
);
} else {
setLoadingStatus(
`Server responded but not healthy (attempt ${connectAttempts})`,
`status: ${data.status}`
);
updateConnection(`http://127.0.0.1:${port}`, data.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.
return;
}
setLoadingStatus(
`Server responded but not healthy (attempt ${connectAttempts})`,
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
);
} else {
setLoadingStatus(
`Server returned ${healthResp.status} (attempt ${connectAttempts})`,
@@ -1355,6 +1370,23 @@ chrome.runtime.onMessage.addListener((msg) => {
fetchRefs();
}
}
// One-time v1.62 identity-pin notice from background.js. Transient banner —
// no dedicated element in sidepanel.html since this fires once per install.
if (msg.type === 'gstack-migration-notice' && msg.message) {
console.log('[gstack sidebar]', msg.message);
try {
const banner = document.createElement('div');
banner.textContent = msg.message;
banner.style.cssText =
'position:fixed;left:8px;right:8px;bottom:40px;z-index:9999;' +
'background:#1f2937;color:#f5a623;border:1px solid #f5a623;' +
'border-radius:6px;padding:8px 10px;font-size:12px;text-align:left;';
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 8000);
} catch (err) {
console.debug('[gstack sidebar] migration banner failed:', err && err.message);
}
}
if (msg.type === 'inspectResult') {
inspectorPickerActive = false;
inspectorPickBtn.classList.remove('active');