diff --git a/package-lock.json b/package-lock.json index 74ac701..a6e8c12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roxy", - "version": "0.0.84", + "version": "0.0.85", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roxy", - "version": "0.0.84", + "version": "0.0.85", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^2.0.85", diff --git a/package.json b/package.json index 4de962b..4e17deb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roxy", - "version": "0.0.84", + "version": "0.0.85", "description": "Roxy — an open-source AI coding agent for engineers.", "main": "./out/main/index.js", "author": "Roxy (https://github.com/roxy-gg/roxy)", @@ -36,11 +36,13 @@ "icons:providers": "node script/copy-provider-icons.mjs", "smoke:shared": "esbuild test/shared.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/shared.cjs && node test/.out/shared.cjs", "smoke:app": "esbuild test/smoke.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/smoke.cjs && electron test/.out/smoke.cjs", - "smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:cookies && npm run smoke:app", + "smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:cookies && npm run smoke:relay && npm run smoke:app", "smoke:cliproxy": "esbuild test/cliproxy.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cliproxy.cjs && electron test/.out/cliproxy.cjs", "worktree:setup": "npm ci --prefer-offline --no-audit --no-fund && electron-builder install-app-deps", "smoke:store": "node test/store-guard.mjs", - "smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs" + "smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs", + "smoke:relay": "esbuild test/relay.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/relay.cjs && electron test/.out/relay.cjs", + "e2e:relay": "esbuild test/relay-e2e.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/relay-e2e.cjs && electron test/.out/relay-e2e.cjs" }, "dependencies": { "@ai-sdk/anthropic": "^2.0.85", diff --git a/resources/session-relay/background.js b/resources/session-relay/background.js new file mode 100644 index 0000000..37bdfbf --- /dev/null +++ b/resources/session-relay/background.js @@ -0,0 +1,317 @@ +/** + * Roxy Session Relay — background service worker. + * + * Owns the connection to Roxy: pairing, the bearer token, and the heartbeat. + * The popup never talks to the network directly; it messages this worker. That + * keeps the token in one place and means a compromised page (which cannot reach + * the worker anyway) has no path to it. + * + * The token lives in `chrome.storage.local`, which is readable only by this + * extension's own contexts — not by web pages, not by other extensions. + */ + +/** Must match RELAY_PORT / RELAY_HEADER in Roxy's shared/relay.ts. */ +const RELAY_ORIGIN = 'http://127.0.0.1:4317' +const RELAY_HEADER = 'x-roxy-relay' + +/** Heartbeat cadence. Drives the "Connected" dot in Roxy's Settings. */ +const HEARTBEAT_MINUTES = 1 + +/** + * Roxy's automation prefs, refreshed on every heartbeat. + * + * Cached so an auto-send decision costs no round trip, but never authoritative: + * Roxy re-checks the blocklist server-side on every snapshot, so a stale cache + * here cannot leak a blocked site. + */ +let prefs = { autoSend: false, trusted: [], blocked: [] } + +/** origin -> last auto-send, so a chatty site cannot spam Roxy. */ +const lastSent = new Map() +const COOLDOWN_MS = 15_000 + +async function getToken() { + const { token } = await chrome.storage.local.get('token') + return token || null +} + +/** POST JSON to the relay, attaching the bearer token when we have one. */ +async function post(path, body, token) { + const headers = { + 'content-type': 'application/json', + // Forces a CORS preflight, so this can never be mistaken for a "simple" + // request that a web page could also make. + [RELAY_HEADER]: '1' + } + if (token) headers.authorization = `Bearer ${token}` + const res = await fetch(`${RELAY_ORIGIN}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body) + }) + let json = null + try { + json = await res.json() + } catch { + // A non-JSON body means something other than Roxy answered on this port. + } + return { ok: res.ok, status: res.status, json } +} + +/** Exchange the on-screen code for a long-lived token. */ +async function pair(code) { + const manifest = chrome.runtime.getManifest() + const r = await post('/pair', { + code, + extensionId: chrome.runtime.id, + browser: detectBrowser(), + version: manifest.version + }) + if (r.ok && r.json?.token) { + await chrome.storage.local.set({ token: r.json.token }) + startHeartbeat() + // Pull prefs straight away rather than waiting up to a minute for the + // first heartbeat — otherwise a freshly trusted site would not auto-send. + void heartbeat() + return { ok: true } + } + return { ok: false, error: r.json?.error || `Pairing failed (${r.status}).` } +} + +/** + * Best-effort browser name for Roxy's UI ("Chrome wants to send…"). + * + * Chromium forks are mostly indistinguishable from the UA string alone; brand + * data from userAgentData is the only reliable signal, and even it falls back + * to Chrome. This is cosmetic, never a security decision. + */ +function detectBrowser() { + const brands = navigator.userAgentData?.brands ?? [] + for (const { brand } of brands) { + if (/edge/i.test(brand)) return 'Edge' + if (/brave/i.test(brand)) return 'Brave' + if (/opera|opr/i.test(brand)) return 'Opera' + if (/vivaldi/i.test(brand)) return 'Vivaldi' + } + if (brands.some((b) => /chromium/i.test(b.brand))) return 'Chromium' + return 'Chrome' +} + +/** Tell Roxy we're alive, so Settings can show a live connection. */ +async function heartbeat() { + const token = await getToken() + if (!token) return + const r = await post('/hello', {}, token) + // 401 means Roxy revoked us (the user hit Disconnect). Drop the dead token + // so the popup prompts to pair again instead of failing silently forever. + if (r.status === 401) await chrome.storage.local.remove('token') + else if (r.json?.prefs) prefs = r.json.prefs +} + +/** + * Suffix match on a dot boundary — the same rule as Roxy's `isBlockedHost`. + * + * The two boundaries that matter: `example.com` must not match + * `notexample.com` (no dot) nor `example.com.evil.net` (not a suffix). + */ +function isBlocked(host) { + const h = String(host || '') + .toLowerCase() + .replace(/\.$/, '') + if (!h) return true + return prefs.blocked.some((raw) => { + const p = String(raw) + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/^\*\./, '') + .split('/')[0] + .replace(/:\d+$/, '') + .replace(/\.$/, '') + return p && (h === p || h.endsWith(`.${p}`)) + }) +} + +/** Is this origin cleared for a hands-off transfer? */ +function mayAutoSend(origin) { + if (!prefs.autoSend) return false + let host + try { + host = new URL(origin).hostname + } catch { + return false + } + if (isBlocked(host)) return false + return prefs.trusted.includes(origin) +} + +/** + * Capture and send a trusted origin without any UI. + * + * Runs only for origins the user already granted host access to, so it never + * triggers a permission prompt: `permissions.contains` is checked first and a + * miss simply means "not ready yet, wait for a manual send". + */ +async function autoSend(origin, tabId) { + if (!mayAutoSend(origin)) return + const last = lastSent.get(origin) ?? 0 + if (Date.now() - last < COOLDOWN_MS) return + + const allowed = await chrome.permissions.contains({ origins: [`${origin}/*`] }).catch(() => false) + if (!allowed) return + + lastSent.set(origin, Date.now()) + try { + const cookies = await chrome.cookies.getAll({ url: `${origin}/` }) + // Nothing to relay yet (signed out, or cookies not set). Don't send an + // empty session — it would overwrite nothing but still churn. + if (!cookies.length) return + + const snapshot = { + v: 1, + origin, + capturedAt: Date.now(), + cookies: cookies.map(toRow) + } + + // localStorage needs the page. Best-effort: a backgrounded or discarded tab + // cannot be scripted, and cookies alone are still worth sending. + if (tabId != null) { + try { + const [res] = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + const out = {} + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null) out[k] = localStorage.getItem(k) ?? '' + } + return out + } + }) + if (res?.result) snapshot.localStorage = res.result + } catch { + // Not scriptable right now; cookies still go. + } + } + + await sendSnapshot(snapshot) + } catch { + // Auto-send is invisible, so a failure must stay invisible too — the next + // cookie change or navigation will try again. + } +} + +/** chrome.cookies.Cookie -> the wire shape (see shared/relay.ts). */ +function toRow(c) { + const row = { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + hostOnly: c.hostOnly, + session: c.session, + sameSite: c.sameSite ?? 'unspecified' + } + if (typeof c.expirationDate === 'number') row.expirationDate = c.expirationDate + if (c.partitionKey) row.partitionKey = c.partitionKey + return row +} + +/** The active tab's origin, or null when it isn't a website. */ +async function activeOrigin() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (!tab?.url) return null + try { + const u = new URL(tab.url) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null + return { origin: u.origin, tabId: tab.id } + } catch { + return null + } +} + +// A cookie changing is the signal that matters: it is what happens when a +// token rotates mid-session, which is the case auto-send exists for. The +// cooldown upstream keeps a busy site from turning this into a flood. +chrome.cookies.onChanged.addListener(async (change) => { + if (change.cause === 'evicted' || change.cause === 'expired') return + const active = await activeOrigin() + if (!active) return + // Only react to cookies that actually belong to the tab in front of the + // user, so a background tab's analytics cannot drive a transfer. + const domain = String(change.cookie?.domain ?? '').replace(/^\./, '') + let host + try { + host = new URL(active.origin).hostname + } catch { + return + } + if (host !== domain && !host.endsWith(`.${domain}`)) return + autoSend(active.origin, active.tabId) +}) + +// Landing on a trusted site should hand its session over without waiting for a +// cookie to change. +chrome.tabs.onUpdated.addListener((tabId, info, tab) => { + if (info.status !== 'complete' || !tab.active || !tab.url) return + try { + const u = new URL(tab.url) + if (u.protocol === 'http:' || u.protocol === 'https:') autoSend(u.origin, tabId) + } catch { + // Not a website. + } +}) + +function startHeartbeat() { + chrome.alarms.create('roxy-heartbeat', { periodInMinutes: HEARTBEAT_MINUTES }) +} + +chrome.alarms?.onAlarm.addListener((alarm) => { + if (alarm.name === 'roxy-heartbeat') heartbeat() +}) + +chrome.runtime.onStartup.addListener(startHeartbeat) +chrome.runtime.onInstalled.addListener(startHeartbeat) + +// A service worker is torn down aggressively and respawned on demand, so the +// cached prefs start empty on every wake. Refresh immediately rather than +// leaving auto-send dormant until the next alarm fires. +void heartbeat() + +/** Send a captured snapshot. Returns Roxy's verdict for the popup to render. */ +async function sendSnapshot(snapshot) { + const token = await getToken() + if (!token) return { ok: false, error: 'Not connected to Roxy yet.' } + const r = await post('/snapshot', snapshot, token) + if (r.ok) return { ok: true } + if (r.status === 401) { + await chrome.storage.local.remove('token') + return { ok: false, error: 'Roxy disconnected this browser. Pair again.' } + } + if (r.status === 413) return { ok: false, error: 'That session is too large to send.' } + return { ok: false, error: r.json?.error || `Roxy rejected the transfer (${r.status}).` } +} + +chrome.runtime.onMessage.addListener((msg, _sender, respond) => { + // Only our own popup can reach this; content scripts and pages cannot. + if (msg?.type === 'pair') pair(msg.code).then(respond) + else if (msg?.type === 'send') sendSnapshot(msg.snapshot).then(respond) + else if (msg?.type === 'status') { + getToken().then((token) => respond({ paired: Boolean(token) })) + } else if (msg?.type === 'unpair') { + chrome.storage.local.remove('token').then(() => respond({ ok: true })) + } else { + return false + } + // Keep the message channel open for the async respond above. + return true +}) + +// Exposed for the end-to-end harness (test/relay-e2e.ts), which drives this +// worker over the DevTools Protocol. `chrome.runtime.sendMessage` cannot be +// used there: a service worker sending to itself has no receiver. Nothing on a +// web page can reach this object — it lives in the worker's own global scope. +globalThis.__roxyRelay = { pair, sendSnapshot, getToken, heartbeat, prefs: () => prefs } diff --git a/resources/session-relay/manifest.json b/resources/session-relay/manifest.json new file mode 100644 index 0000000..34fab65 --- /dev/null +++ b/resources/session-relay/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "Roxy Session Relay", + "version": "1.0.0", + "description": "Send a site's cookies and storage to the Roxy app, so you can debug a signed-in page without signing in again.", + + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzJm9L3DATBNEUCb8T0VgC+46UXFa4KtpS9Kzz24MXeeHonZjESbCfBSJzjnMBMSdAWmAY2Spa3v7ozCGibL0Jy05tlPVfE8eJ1n98T2gW72Od3eBi7ToaDHiDPmuYKa1qxbb0CQTpyLvwRSJWyo8J16TMiGcShojtw/o+Ek1xfSaFT5JVy/klid7VSSSk/eCwqppVp0zAouoX/TtYMogaQlcXSNZfL4AuayYi+9Utd3y29A4K97Pe0RucFk6bMGm4Q+ypqA+HTxzDGekHYQMVmZQcjQ4iFsxOYcvNJyqPmrR64+mbqHltYo7Pie1TZ74gOl1ba6dQdmUaDg2vZChhwIDAQAB", + + "permissions": ["activeTab", "alarms", "cookies", "scripting", "storage"], + + "host_permissions": ["http://127.0.0.1/*"], + + "optional_host_permissions": ["http://*/*", "https://*/*"], + + "action": { + "default_title": "Roxy Session Relay", + "default_popup": "popup.html" + }, + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src http://127.0.0.1:4317" + } +} diff --git a/resources/session-relay/popup.html b/resources/session-relay/popup.html new file mode 100644 index 0000000..4d5d000 --- /dev/null +++ b/resources/session-relay/popup.html @@ -0,0 +1,124 @@ + + + + + + + +
+ + + diff --git a/resources/session-relay/popup.js b/resources/session-relay/popup.js new file mode 100644 index 0000000..eff0af1 --- /dev/null +++ b/resources/session-relay/popup.js @@ -0,0 +1,311 @@ +/** + * Roxy Session Relay — popup. + * + * Two states: pair (enter the code Roxy is showing) and send (pick what to + * transfer for the current tab). All network I/O goes through the background + * worker, which owns the token. + * + * Nothing is captured until "Send session" is pressed. Site access is requested + * at that moment via `chrome.permissions.request`, so installing this extension + * does not grant it access to every page you visit. + */ + +const app = document.getElementById('app') + +/** Ask the worker whether we already hold a token. */ +async function status() { + return chrome.runtime.sendMessage({ type: 'status' }) +} + +/** The tab the user is looking at — the only one we ever touch. */ +async function currentTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + return tab ?? null +} + +function originOf(url) { + try { + const u = new URL(url) + return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null + } catch { + return null + } +} + +function el(html) { + const t = document.createElement('template') + t.innerHTML = html.trim() + return t.content.firstElementChild +} + +// ---- pairing --------------------------------------------------------------- + +function renderPair(error) { + app.replaceChildren( + el(` +
+
+

Connect to Roxy

+

Enter the 6-digit code shown in Roxy under Settings › Browser.

+
+ + + ${error ? `
${escapeHtml(error)}
` : ''} +
Roxy must be running for this to work.
+
+ `) + ) + + const code = app.querySelector('#code') + const go = app.querySelector('#go') + code.addEventListener('input', () => { + code.value = code.value.replace(/\D/g, '').slice(0, 6) + go.disabled = code.value.length !== 6 + }) + code.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !go.disabled) go.click() + }) + go.addEventListener('click', async () => { + go.disabled = true + go.textContent = 'Connecting…' + const r = await chrome.runtime.sendMessage({ type: 'pair', code: code.value }) + if (r?.ok) render() + else renderPair(r?.error ?? 'Could not reach Roxy. Is it running?') + }) + code.focus() +} + +// ---- sending --------------------------------------------------------------- + +async function renderSend() { + const tab = await currentTab() + const origin = tab ? originOf(tab.url) : null + + if (!origin) { + app.replaceChildren( + el(` +
+
+

Roxy Session Relay

+

Open a website first — this page has no session to send.

+
+ +
+ `) + ) + wireDisconnect() + return + } + + app.replaceChildren( + el(` +
+
+

Send session to Roxy

+

${escapeHtml(origin)}

+
+
+ + + +
+ +
+
+
+ +
+ `) + ) + wireDisconnect() + + const msg = app.querySelector('#msg') + const send = app.querySelector('#send') + + send.addEventListener('click', async () => { + send.disabled = true + send.textContent = 'Capturing…' + msg.className = '' + msg.textContent = '' + + const want = { + cookies: app.querySelector('#c-cookies').checked, + localStorage: app.querySelector('#c-local').checked, + sessionStorage: app.querySelector('#c-session').checked + } + + try { + // Site access is requested HERE, from a user gesture, and only for this + // origin — not at install time for every site. + const granted = await chrome.permissions.request({ origins: [`${origin}/*`] }) + if (!granted) throw new Error('Access to this site was not granted.') + + // Now that cookies are readable, show the true counts rather than the + // dashes that were there a moment ago. + void refreshCounts(tab, origin) + + const snapshot = await capture(tab, origin, want) + // Sending zero cookies for a site you are signed into looks like success + // but is not, so warn instead of silently relaying an empty session. + if (want.cookies && snapshot.cookies.length === 0) { + throw new Error('No cookies were readable for this site. Try reloading the page.') + } + send.textContent = 'Sending…' + const r = await chrome.runtime.sendMessage({ type: 'send', snapshot }) + if (!r?.ok) throw new Error(r?.error ?? 'Roxy rejected the transfer.') + msg.className = 'msg ok' + msg.textContent = 'Sent. Confirm the import in Roxy.' + send.textContent = 'Send again' + } catch (e) { + msg.className = 'msg err' + msg.textContent = e?.message ?? String(e) + send.textContent = 'Send session' + } finally { + send.disabled = false + } + }) + + // Show counts up front so the user knows what they're about to hand over. + void refreshCounts(tab, origin) +} + +/** Fill in the three counts, distinguishing "none" from "not allowed to look". */ +async function refreshCounts(tab, origin) { + const { cookies, local, session } = await preview(tab, origin) + const set = (id, v) => { + const node = app.querySelector(id) + if (node) node.textContent = v == null ? '—' : String(v) + } + set('#n-cookies', cookies) + set('#n-local', local) + set('#n-session', session) + + // A dash means "unknown until you grant access", which is not obvious on its + // own — say so, or an empty-looking list reads as "there is nothing here". + const hint = app.querySelector('#count-hint') + if (hint) + hint.textContent = cookies == null ? 'Counts appear once you allow access to this site.' : '' +} + +/** + * Counts for the checkboxes. + * + * `null` means UNKNOWN, not zero. The distinction matters: until the user + * grants host access for this origin, `chrome.cookies.getAll` returns an empty + * array rather than failing — "This method only retrieves cookies for domains + * that the extension has host permissions to." Rendering that as `0` would + * claim a signed-in site has no cookies, which is exactly backwards. + * + * Storage is readable earlier because `activeTab` grants a temporary host + * permission when the user opens the popup, and `chrome.scripting` honours it. + * The cookies API does not, so the two can legitimately disagree. + */ +async function preview(tab, origin) { + const out = { cookies: null, local: null, session: null } + + const allowed = await chrome.permissions.contains({ origins: [`${origin}/*`] }).catch(() => false) + if (allowed) { + try { + out.cookies = (await chrome.cookies.getAll({ url: `${origin}/` })).length + } catch { + // Left as null: unknown, not zero. + } + } + + try { + const [res] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => [localStorage.length, sessionStorage.length] + }) + if (res?.result) { + out.local = res.result[0] + out.session = res.result[1] + } + } catch { + // Page blocks injection (a chrome:// page, a PDF viewer): stays unknown. + } + return out +} + +/** Build the snapshot Roxy expects (see shared/relay.ts). */ +async function capture(tab, origin, want) { + const snapshot = { + v: 1, + origin, + title: tab.title ?? undefined, + capturedAt: Date.now(), + cookies: [] + } + + if (want.cookies) { + // `getAll({ url })` returns every cookie that would be SENT to that URL, + // including HttpOnly ones the page itself cannot read — which is the whole + // reason this goes through the extension API instead of document.cookie. + const raw = await chrome.cookies.getAll({ url: `${origin}/` }) + snapshot.cookies = raw.map((c) => { + const row = { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + hostOnly: c.hostOnly, + session: c.session, + sameSite: c.sameSite ?? 'unspecified' + } + if (typeof c.expirationDate === 'number') row.expirationDate = c.expirationDate + // Carried so Roxy can report it was skipped: partitioned cookies cannot + // be faithfully recreated there. + if (c.partitionKey) row.partitionKey = c.partitionKey + return row + }) + } + + if (want.localStorage || want.sessionStorage) { + const [res] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + args: [want.localStorage, want.sessionStorage], + func: (wantLocal, wantSession) => { + const dump = (store) => { + const out = {} + for (let i = 0; i < store.length; i++) { + const k = store.key(i) + if (k != null) out[k] = store.getItem(k) ?? '' + } + return out + } + return { + local: wantLocal ? dump(localStorage) : undefined, + session: wantSession ? dump(sessionStorage) : undefined + } + } + }) + if (res?.result?.local) snapshot.localStorage = res.result.local + if (res?.result?.session) snapshot.sessionStorage = res.result.session + } + + return snapshot +} + +function wireDisconnect() { + app.querySelector('#disconnect')?.addEventListener('click', async () => { + await chrome.runtime.sendMessage({ type: 'unpair' }) + render() + }) +} + +function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ) +} + +async function render() { + const s = await status() + if (s?.paired) renderSend() + else renderPair() +} + +render() diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index 5b707fb..94dbb8a 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -133,6 +133,48 @@ function setSetting(key: string, value: string | null): void { ).run(key, value) } +// ---- Session Relay pairing --------------------------------------------- +// The relay's bearer token, encrypted by the caller (see services/relay.ts) +// before it gets here. Stored as one JSON blob in `settings` for the same +// reason the forge host map below is: a single row, no relations, and +// therefore no migration - which matters because migrations are append-only. +// +// NOT in `credentials`: that table is keyed by provider_id with a foreign key +// into `providers`, and the relay is not a model provider. + +const RELAY_PAIRING_KEY = 'session_relay_pairing' + +/** The stored relay pairing blob, or null when nothing is paired. */ +export function getRelayPairing(): string | null { + const row = getDb().prepare('SELECT value FROM settings WHERE key = ?').get(RELAY_PAIRING_KEY) as + | { value: string } + | undefined + return row?.value ?? null +} + +/** Persist (or clear, with null) the relay pairing blob. */ +export function setRelayPairing(value: string | null): void { + setSetting(RELAY_PAIRING_KEY, value) +} + +// The relay's automation prefs (auto-send switch, trusted origins, blocklist). +// Stored in the clear: these are hostnames, not credentials, and a blocklist +// you cannot read is a blocklist you cannot fix. +const RELAY_PREFS_KEY = 'session_relay_prefs' + +/** The stored relay automation prefs, or null before anything is set. */ +export function getRelayPrefs(): string | null { + const row = getDb().prepare('SELECT value FROM settings WHERE key = ?').get(RELAY_PREFS_KEY) as + | { value: string } + | undefined + return row?.value ?? null +} + +/** Persist (or clear, with null) the relay automation prefs. */ +export function setRelayPrefs(value: string | null): void { + setSetting(RELAY_PREFS_KEY, value) +} + // ---- Forge host overrides ---------------------------------------------- // Which software an UNRECOGNISED git host runs (`git.mycorp.com` -> gitlab). // Only consulted when auto-detection fails, so a stale or mistaken answer can diff --git a/src/main/index.ts b/src/main/index.ts index d1e2ce4..0ed55bd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import macDockIcon from '../../resources/icon-mac.png?asset' import { registerIpc } from './ipc' import { getDb } from './db/database' import { startLoopScheduler } from './services/loops' +import { start as startRelay, stop as stopRelay } from './services/relay' import { listModels } from './services/models' import { backfillUsageFromHistory } from './services/usage' import { listConnectedProviders } from './db/repo' @@ -111,6 +112,9 @@ app.whenReady().then(() => { // own storage - a failure in it can't touch either. initTracking() startLoopScheduler() + // The Session Relay listener. Loopback-only and inert until an extension + // pairs, so starting it unconditionally costs one idle socket. + void startRelay() // Sweep tool-output spill files older than the retention window (best-effort). void cleanupToolOutputs() // One-time: seed the usage/cost table from existing message history so the @@ -146,6 +150,7 @@ app.on('will-quit', () => { killAllBackground() cancelAllBackgroundJobs() closeAllBrowsers() + stopRelay() shutdownAllLsp() void shutdownAllMcp() shutdownRemote() diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 2498c61..5373fca 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -30,6 +30,8 @@ import * as copilot from '../services/copilot' import * as cliproxy from '../services/cliproxy' import * as browser from '../services/browser' import * as cookies from '../services/cookies' +import * as relay from '../services/relay' +import * as relayInstall from '../services/relay-install' import { listModels } from '../services/models' import { pickDefaultModel } from '../../shared/models' import { CLIPROXY_PROVIDER_IDS, accountsFor, isCliProxyProvider } from '../../shared/cliproxy' @@ -49,6 +51,7 @@ import { sessionCwd } from '../services/workspace' import * as git from '../services/git' import * as forge from '../services/forge' import type { ForgeKind } from '../../shared/forge' +import type { RelayImportChoice, RelayPrefs } from '../../shared/relay' import { pruneWorktrees, removeWorktreeForChat, renameWorkstreamBranch } from '../services/worktree' import { checkForUpdates, quitAndInstall, getUpdateState } from '../services/updater' import { @@ -781,6 +784,26 @@ export function registerIpc(): void { browser.setChromeHeight(height, keyOf(e)) ) + // ---- session relay (the paired browser extension) ---- + // The relay listener itself is NOT reachable from here; these only drive + // pairing and the user's approve/reject of a queued snapshot. See + // services/relay.ts for why that split is the safety boundary. + ipcMain.handle(CHANNELS.relayStatus, () => relay.status()) + ipcMain.handle(CHANNELS.relayBeginPairing, () => relay.beginPairing()) + ipcMain.handle(CHANNELS.relayCancelPairing, () => relay.cancelPairing()) + ipcMain.handle(CHANNELS.relayUnpair, () => relay.unpair()) + ipcMain.handle( + CHANNELS.relayApply, + (_e, id: string, choice: RelayImportChoice, trust?: boolean) => + relay.applyPending(id, choice, trust) + ) + ipcMain.handle(CHANNELS.relayReject, (_e, id: string) => relay.rejectPending(id)) + ipcMain.handle(CHANNELS.relayInstallExtension, () => relayInstall.install()) + ipcMain.handle(CHANNELS.relayRevealExtension, () => relayInstall.reveal()) + ipcMain.handle(CHANNELS.relaySetPrefs, (_e, p: Partial) => relay.setPrefs(p)) + ipcMain.handle(CHANNELS.relayTrustOrigin, (_e, o: string) => relay.trustOrigin(o)) + ipcMain.handle(CHANNELS.relayUntrustOrigin, (_e, o: string) => relay.untrustOrigin(o)) + // ---- services (a session's background processes) ---- // Every handler resolves the ROOT session first: a subagent's dev server is // registered under its parent, and the parent's panel is where it belongs. diff --git a/src/main/services/browser.ts b/src/main/services/browser.ts index 9887b21..32360b9 100644 --- a/src/main/services/browser.ts +++ b/src/main/services/browser.ts @@ -314,6 +314,32 @@ export function keyForContents(wc: Electron.WebContents): string | null { return null } +/** + * An open tab currently sitting on `origin`, across every session's browser. + * + * Session storage is scoped to one browsing context, so relaying it needs a + * LIVE tab on the target origin - there is nothing else to write into. + * Prefers a session's ACTIVE tab, since that is the one the user is looking at. + */ +export function contentsForOrigin(origin: string): Electron.WebContents | null { + let fallback: Electron.WebContents | null = null + for (const s of sessions.values()) { + for (const t of s.tabs) { + if (t.view.webContents.isDestroyed()) continue + let tabOrigin: string + try { + tabOrigin = new URL(t.view.webContents.getURL()).origin + } catch { + continue + } + if (tabOrigin !== origin) continue + if (t.id === s.activeTabId) return t.view.webContents + fallback ??= t.view.webContents + } + } + return fallback +} + /** Label a session's window (usually the project folder), so windows are tellable apart. */ export function setLabel(key: string, label: string): void { if (key === DEFAULT_KEY) return diff --git a/src/main/services/relay-install.ts b/src/main/services/relay-install.ts new file mode 100644 index 0000000..bc6e65c --- /dev/null +++ b/src/main/services/relay-install.ts @@ -0,0 +1,73 @@ +/** + * Installing the bundled Session Relay extension where a real browser can load + * it. + * + * Chrome cannot load an unpacked extension from inside an asar archive, and on + * a packaged build our files live under `resources/` (kept unpacked via + * `asarUnpack` in electron-builder.yml). Rather than point the user at a path + * buried in the app bundle — which breaks on every update, and which macOS + * hides — we COPY the extension into the user's Documents folder. That path is + * stable, greppable, and survives Roxy updating underneath it. + * + * The copy is re-run on every install click so an app update refreshes the + * extension in place; the user just hits Reload in their browser. + */ +import { app, shell } from 'electron' +import { cp, mkdir, readFile, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** Folder name under Documents. Recognisable in a "Load unpacked" dialog. */ +const FOLDER = 'Roxy Session Relay' + +/** Where the extension ships inside the app. */ +function sourceDir(): string { + // Packaged: resources are unpacked next to the asar. Dev: straight from the + // repo, since `resources/` is not copied into out/. + return app.isPackaged + ? join(process.resourcesPath, 'resources', 'session-relay') + : join(app.getAppPath(), 'resources', 'session-relay') +} + +/** Where the user loads it from. */ +export function installDir(): string { + return join(app.getPath('documents'), FOLDER) +} + +export interface InstallResult { + path: string + version: string +} + +/** + * Copy the extension to Documents, replacing any previous copy. + * + * We delete first rather than copy over the top: a stale file from an older + * version (a renamed script, a dropped asset) would otherwise linger and could + * break the load with a confusing manifest error. + */ +export async function install(): Promise { + const src = sourceDir() + if (!existsSync(src)) { + throw new Error(`The bundled extension is missing from this build (${src}).`) + } + const dest = installDir() + await rm(dest, { recursive: true, force: true }) + await mkdir(dest, { recursive: true }) + await cp(src, dest, { recursive: true }) + + let version = '' + try { + version = JSON.parse(await readFile(join(dest, 'manifest.json'), 'utf8')).version ?? '' + } catch { + // Non-fatal: the copy is what matters, the version is only shown in the UI. + } + return { path: dest, version } +} + +/** Open the folder in the OS file manager, so "Load unpacked" is a short trip. */ +export async function reveal(): Promise { + const dir = installDir() + if (!existsSync(dir)) throw new Error('Install the extension first.') + await shell.openPath(dir) +} diff --git a/src/main/services/relay.ts b/src/main/services/relay.ts new file mode 100644 index 0000000..e5709d8 --- /dev/null +++ b/src/main/services/relay.ts @@ -0,0 +1,717 @@ +/** + * The Session Relay listener — an authenticated loopback endpoint the bundled + * Chrome extension posts a site's session to. + * + * See ../../shared/relay.ts for the protocol and the threat model. The short + * version: a snapshot is live credentials, loopback is reachable by any process + * AND (via DNS rebinding) by any web page, so every request must prove it came + * from the paired extension. This file is where that is enforced, and it is + * deliberately the only place that can accept a snapshot. + * + * Routes (all POST, all JSON): + * /pair { code, extensionId, browser, version } -> { token } + * /hello auth -> { ok } .............. heartbeat, drives "Connected" + * /snapshot auth + RelaySnapshot -> { queued } ..... needs user approval + * + * Nothing here ever WRITES to the browser partition. A snapshot is parked in + * memory and surfaced to the UI; only an explicit `applyPending` from the + * renderer touches cookies or storage. That split is the whole safety story: + * compromise of the endpoint alone cannot silently inject a session. + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { BrowserWindow } from 'electron' +import { + AUTO_SEND_COOLDOWN_MS, + DEFAULT_RELAY_PREFS, + MAX_SNAPSHOT_BYTES, + PAIRING_TTL_MS, + RELAY_HEADER, + RELAY_PORT, + RELAY_PROTOCOL_VERSION, + isAutoAllowed, + isBlockedOrigin, + isImportableOrigin, + type PendingSnapshot, + type RelayCookie, + type RelayImportChoice, + type RelayImportResult, + type RelayPrefs, + type RelaySnapshot, + type RelayStatus, + type RelayTransfer +} from '../../shared/relay' +import { CHANNELS } from '../../shared/ipc' +import { decryptSecret, encryptSecret } from './secure' +import * as repo from '../db/repo' +import * as cookies from './cookies' +import * as storage from './storage' +import * as browser from './browser' + +/** A completed pairing. Persisted encrypted; see `load`/`persist` below. */ +interface Pairing { + token: string + extensionId: string + browser: string + version: string +} + +interface State { + server: Server | null + port: number + pairing: Pairing | null + /** The code currently on screen, if the user is mid-pairing. */ + code: { value: string; expiresAt: number } | null + lastSeenAt?: number + lastTransferAt?: number + /** Snapshots awaiting approval, newest last. Values live ONLY here. */ + pending: { meta: PendingSnapshot; snapshot: RelaySnapshot }[] + /** Automation settings: master switch, trusted origins, blocklist. */ + prefs: RelayPrefs + /** Recent transfers for the Settings activity list. Never includes values. */ + recent: RelayTransfer[] + /** origin -> last auto-apply time, for the per-origin cooldown. */ + lastAuto: Map +} + +const state: State = { + server: null, + port: RELAY_PORT, + pairing: null, + code: null, + pending: [], + prefs: { ...DEFAULT_RELAY_PREFS }, + recent: [], + lastAuto: new Map() +} + +/** How many transfers to remember for the activity list. */ +const MAX_RECENT = 20 + +/** + * Cap on parked snapshots. A paired-but-malicious extension could otherwise + * queue unbounded credential blobs into main's memory. + */ +const MAX_PENDING = 5 + +/** Constant-time compare that also tolerates length mismatch without leaking it. */ +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a, 'utf8') + const bb = Buffer.from(b, 'utf8') + if (ba.length !== bb.length) { + // Still burn a comparison so timing doesn't reveal "wrong length" vs + // "wrong value" — the branch above is not secret, the contents are. + timingSafeEqual(ba, ba) + return false + } + return timingSafeEqual(ba, bb) +} + +/** A 6-digit pairing code. Human-typable; only useful inside its 3-minute TTL. */ +function makeCode(): string { + // rejection-free: 3 bytes -> 0..16777215, mod 1e6 bias is ~6e-8, irrelevant + // for a 3-minute single-use code guarded by a rate limit. + return String(randomBytes(3).readUIntBE(0, 3) % 1_000_000).padStart(6, '0') +} + +/** Wrong-code attempts since the last success, to blunt online guessing. */ +let failedPairAttempts = 0 +const MAX_PAIR_ATTEMPTS = 10 + +function json(res: ServerResponse, status: number, body: unknown): void { + const text = JSON.stringify(body) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + // Nothing here is cacheable and none of it should ever be stored. + 'cache-control': 'no-store', + // The relay is not a website; refuse to be framed or sniffed. + 'x-content-type-options': 'nosniff' + }) + res.end(text) +} + +/** + * The origin we accept, once paired: exactly this extension, nothing else. + * Before pairing there is no such origin, so /pair accepts any extension origin + * but demands the on-screen code instead. + */ +function pairedOrigin(): string | null { + return state.pairing ? `chrome-extension://${state.pairing.extensionId}` : null +} + +/** + * Reject anything that isn't the paired extension talking to loopback. + * + * Order matters only for clarity; all four checks must pass: + * - Host pins us to 127.0.0.1:, defeating DNS rebinding (a page on + * evil.com resolved to 127.0.0.1 would send `Host: evil.com`). + * - The custom header forces a preflight, so no page can reach this with a + * simple cross-origin POST. + * - Origin must be the paired extension. Browsers set this header themselves + * and script cannot override it, so this is the load-bearing check. + * - The bearer token proves it's our extension and not another one. + */ +function authed(req: IncomingMessage): boolean { + const p = state.pairing + if (!p) return false + + const host = String(req.headers.host ?? '') + if (host !== `127.0.0.1:${state.port}` && host !== `localhost:${state.port}`) return false + + if (String(req.headers[RELAY_HEADER] ?? '') !== '1') return false + + const origin = String(req.headers.origin ?? '') + if (origin !== pairedOrigin()) return false + + const auth = String(req.headers.authorization ?? '') + if (!auth.startsWith('Bearer ')) return false + return safeEqual(auth.slice(7), p.token) +} + +/** Read a JSON body, refusing anything oversized before buffering it all. */ +async function readJson(req: IncomingMessage, limit: number): Promise { + const declared = Number(req.headers['content-length'] ?? 0) + if (declared > limit) throw new Error('payload too large') + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of req) { + total += (chunk as Buffer).length + // Enforce on the stream too: content-length can lie or be absent. + if (total > limit) throw new Error('payload too large') + chunks.push(chunk as Buffer) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +/** Push status to every window so Settings updates without polling. */ +function broadcast(): void { + const s = status() + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send(CHANNELS.relayState, s) + } +} + +/** Current relay state. Never includes the token or any snapshot VALUES. */ +export function status(): RelayStatus { + return { + listening: Boolean(state.server?.listening), + port: state.port, + paired: Boolean(state.pairing), + extensionId: state.pairing?.extensionId, + browser: state.pairing?.browser, + extensionVersion: state.pairing?.version, + lastSeenAt: state.lastSeenAt, + lastTransferAt: state.lastTransferAt, + pairingExpiresAt: state.code?.expiresAt, + pending: state.pending.map((p) => p.meta), + prefs: state.prefs, + recent: state.recent + } +} + +/** Automation settings, as the extension needs them. */ +export function prefs(): RelayPrefs { + return state.prefs +} + +/** + * Replace the automation settings. + * + * Adding a block also revokes any trust for origins it now covers. Without + * that, blocking `example.com` while `https://app.example.com` sat in the + * trusted list would leave a contradiction on screen — and although + * `isAutoAllowed` checks blocks first (so it would be honoured), a UI that + * displays a trusted entry which silently does nothing is a bug waiting to be + * misread. + */ +export function setPrefs(next: Partial): RelayPrefs { + const merged: RelayPrefs = { ...state.prefs, ...next } + merged.trusted = merged.trusted.filter((o) => !isBlockedOrigin(o, merged.blocked)) + state.prefs = merged + persistPrefs() + broadcast() + return state.prefs +} + +/** Mark an origin as needing no further confirmation. */ +export function trustOrigin(origin: string): RelayPrefs { + if (!isImportableOrigin(origin)) throw new Error(`Cannot trust "${origin}".`) + if (isBlockedOrigin(origin, state.prefs.blocked)) { + throw new Error('That site is on the blocklist. Remove it there first.') + } + if (state.prefs.trusted.includes(origin)) return state.prefs + return setPrefs({ trusted: [...state.prefs.trusted, origin] }) +} + +export function untrustOrigin(origin: string): RelayPrefs { + return setPrefs({ trusted: state.prefs.trusted.filter((o) => o !== origin) }) +} + +/** Rough byte size of the credential material, for the confirmation prompt. */ +function sizeOf(snap: RelaySnapshot): number { + let n = 0 + for (const c of snap.cookies) n += c.name.length + c.value.length + for (const rec of [snap.localStorage, snap.sessionStorage]) { + if (!rec) continue + for (const [k, v] of Object.entries(rec)) n += k.length + v.length + } + return n +} + +/** + * Validate an untrusted snapshot into the shape the rest of the app assumes. + * Returns null when it's unusable. Everything crossing this boundary is + * attacker-controlled, so nothing is taken on trust — not the types, not the + * origin, not the array contents. + */ +function parseSnapshot(raw: unknown): RelaySnapshot | null { + if (!raw || typeof raw !== 'object') return null + const o = raw as Record + if (o.v !== RELAY_PROTOCOL_VERSION) return null + const origin = String(o.origin ?? '') + if (!isImportableOrigin(origin)) return null + + const cookies: RelayCookie[] = [] + if (Array.isArray(o.cookies)) { + for (const item of o.cookies) { + if (!item || typeof item !== 'object') continue + const c = item as Record + const name = String(c.name ?? '') + const domain = String(c.domain ?? '') + if (!name || !domain) continue + const row: RelayCookie = { + name, + value: String(c.value ?? ''), + domain, + path: String(c.path ?? '/') || '/', + secure: Boolean(c.secure), + httpOnly: Boolean(c.httpOnly), + hostOnly: Boolean(c.hostOnly), + session: Boolean(c.session), + sameSite: normalizeSameSite(c.sameSite) + } + if (typeof c.expirationDate === 'number' && Number.isFinite(c.expirationDate)) { + row.expirationDate = c.expirationDate + } + if (c.partitionKey && typeof c.partitionKey === 'object') { + const pk = c.partitionKey as Record + row.partitionKey = { + topLevelSite: pk.topLevelSite ? String(pk.topLevelSite) : undefined, + hasCrossSiteAncestor: Boolean(pk.hasCrossSiteAncestor) + } + } + cookies.push(row) + } + } + + const strings = (v: unknown): Record | undefined => { + if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined + const out: Record = {} + for (const [k, val] of Object.entries(v as Record)) { + if (typeof val === 'string') out[k] = val + } + return out + } + + return { + v: RELAY_PROTOCOL_VERSION, + origin, + title: o.title ? String(o.title).slice(0, 200) : undefined, + capturedAt: typeof o.capturedAt === 'number' ? o.capturedAt : Date.now(), + cookies, + localStorage: strings(o.localStorage), + sessionStorage: strings(o.sessionStorage) + } +} + +function normalizeSameSite(raw: unknown): RelayCookie['sameSite'] { + const v = String(raw ?? '') + .trim() + .toLowerCase() + if (v === 'no_restriction' || v === 'none') return 'no_restriction' + if (v === 'lax') return 'lax' + if (v === 'strict') return 'strict' + return 'unspecified' +} + +async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`) + + // CORS preflight. We answer for the paired extension only; before pairing we + // must also allow an extension origin through so /pair itself is reachable. + if (req.method === 'OPTIONS') { + const origin = String(req.headers.origin ?? '') + const allowed = pairedOrigin() + const ok = allowed ? origin === allowed : origin.startsWith('chrome-extension://') + if (!ok) return void json(res, 403, { error: 'origin not allowed' }) + res.writeHead(204, { + 'access-control-allow-origin': origin, + 'access-control-allow-methods': 'POST, OPTIONS', + 'access-control-allow-headers': `content-type, authorization, ${RELAY_HEADER}`, + 'access-control-max-age': '600', + vary: 'origin' + }) + return void res.end() + } + + if (req.method !== 'POST') return void json(res, 405, { error: 'method not allowed' }) + + // ---- pairing ------------------------------------------------------------- + if (url.pathname === '/pair') { + const origin = String(req.headers.origin ?? '') + if (!origin.startsWith('chrome-extension://')) { + return void json(res, 403, { error: 'origin not allowed' }) + } + if (!state.code || Date.now() > state.code.expiresAt) { + return void json(res, 409, { error: 'no pairing in progress' }) + } + if (failedPairAttempts >= MAX_PAIR_ATTEMPTS) { + state.code = null + broadcast() + return void json(res, 429, { error: 'too many attempts; start pairing again' }) + } + let body: Record + try { + body = (await readJson(req, 4096)) as Record + } catch { + return void json(res, 400, { error: 'bad request' }) + } + if (!safeEqual(String(body.code ?? ''), state.code.value)) { + failedPairAttempts++ + return void json(res, 401, { error: 'wrong code' }) + } + // The extension id must match the origin that carried the request, or a + // second extension could claim the first one's identity. + const extensionId = String(body.extensionId ?? '') + if (!extensionId || origin !== `chrome-extension://${extensionId}`) { + return void json(res, 400, { error: 'extension id does not match origin' }) + } + + state.pairing = { + token: randomBytes(32).toString('base64url'), + extensionId, + browser: String(body.browser ?? 'Browser').slice(0, 40), + version: String(body.version ?? '').slice(0, 20) + } + state.code = null + failedPairAttempts = 0 + state.lastSeenAt = Date.now() + persist() + broadcast() + return void json(res, 200, { token: state.pairing.token }) + } + + // ---- everything below requires a completed pairing ----------------------- + if (!authed(req)) return void json(res, 401, { error: 'unauthorized' }) + + if (url.pathname === '/hello') { + state.lastSeenAt = Date.now() + broadcast() + // The extension caches prefs so it can decide whether to auto-send without + // a round trip. Returning them on every heartbeat keeps Roxy the single + // source of truth: a block takes effect within one heartbeat, and the + // server enforces it independently anyway (see /snapshot). + return void json(res, 200, { ok: true, v: RELAY_PROTOCOL_VERSION, prefs: state.prefs }) + } + + if (url.pathname === '/snapshot') { + if (state.pending.length >= MAX_PENDING) { + return void json(res, 429, { error: 'too many snapshots awaiting approval' }) + } + let raw: unknown + try { + raw = await readJson(req, MAX_SNAPSHOT_BYTES) + } catch (e) { + const tooBig = e instanceof Error && e.message === 'payload too large' + return void json(res, tooBig ? 413 : 400, { error: tooBig ? 'too large' : 'bad request' }) + } + const snapshot = parseSnapshot(raw) + if (!snapshot) return void json(res, 400, { error: 'unsupported or malformed snapshot' }) + + // THE BLOCKLIST IS ENFORCED HERE, not just in the extension. The extension + // decides whether to SEND; this decides whether we will even hold it. An + // extension running stale prefs — or simply an older build — must not be + // able to relay a blocked site. + if (isBlockedOrigin(snapshot.origin, state.prefs.blocked)) { + return void json(res, 403, { error: 'that site is blocked in Roxy' }) + } + + state.lastSeenAt = Date.now() + + // Trusted origin: apply now, no prompt. This is the "no clicking" path. + if (isAutoAllowed(snapshot.origin, state.prefs)) { + const last = state.lastAuto.get(snapshot.origin) ?? 0 + if (Date.now() - last < AUTO_SEND_COOLDOWN_MS) { + // Not an error: the extension is allowed to be chatty, we just decline + // to redo the work. Reported so it can back off. + return void json(res, 200, { applied: false, throttled: true }) + } + state.lastAuto.set(snapshot.origin, Date.now()) + const result = await applySnapshot(snapshot, { + cookies: true, + localStorage: true, + sessionStorage: true + }) + record(snapshot, result, true) + broadcast() + return void json(res, 200, { applied: true }) + } + + const partitioned = snapshot.cookies.filter((c) => c.partitionKey?.topLevelSite).length + const meta: PendingSnapshot = { + id: randomUUID(), + origin: snapshot.origin, + title: snapshot.title, + receivedAt: Date.now(), + browser: state.pairing?.browser ?? 'Browser', + cookieCount: snapshot.cookies.length, + partitionedCookieCount: partitioned, + localStorageCount: Object.keys(snapshot.localStorage ?? {}).length, + sessionStorageCount: Object.keys(snapshot.sessionStorage ?? {}).length, + approxBytes: sizeOf(snapshot) + } + state.pending.push({ meta, snapshot }) + broadcast() + // Bring Roxy forward: the snapshot is useless until the user answers, and + // they just clicked "Send" in another app and are looking for the result. + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) + win?.show() + return void json(res, 202, { queued: true, id: meta.id }) + } + + return void json(res, 404, { error: 'not found' }) +} + +/** Start listening. Idempotent; safe to call on every app start. */ +export async function start(): Promise { + if (state.server) return + load() + const server = createServer((req, res) => { + handle(req, res).catch(() => { + if (!res.headersSent) json(res, 500, { error: 'internal error' }) + else res.end() + }) + }) + // Loopback ONLY. Binding 0.0.0.0 would expose a credential sink to the LAN. + await new Promise((resolve) => { + server.once('error', () => resolve()) // port busy: stay down, surface via status() + server.listen(RELAY_PORT, '127.0.0.1', () => resolve()) + }) + state.server = server.listening ? server : null + broadcast() +} + +export function stop(): void { + state.server?.close() + state.server = null + // Parked snapshots are credentials; never outlive the process. + state.pending = [] + broadcast() +} + +/** Begin pairing: mint a code for the user to type into the extension. */ +export function beginPairing(): { code: string; expiresAt: number; port: number } { + const code = makeCode() + state.code = { value: code, expiresAt: Date.now() + PAIRING_TTL_MS } + failedPairAttempts = 0 + broadcast() + return { code, expiresAt: state.code.expiresAt, port: state.port } +} + +export function cancelPairing(): void { + state.code = null + broadcast() +} + +/** Forget the paired extension; its token stops working immediately. */ +export function unpair(): void { + state.pairing = null + state.code = null + state.pending = [] + persist() + broadcast() +} + +/** Take a parked snapshot out of the queue (approve or reject both consume it). */ +export function takePending(id: string): RelaySnapshot | null { + const i = state.pending.findIndex((p) => p.meta.id === id) + if (i < 0) return null + const [entry] = state.pending.splice(i, 1) + broadcast() + return entry.snapshot +} + +export function markTransferred(): void { + state.lastTransferAt = Date.now() + broadcast() +} + +/** + * Apply a snapshot to the browser partition. + * + * Split out from `applyPending` so the auto path and the manual path share one + * implementation — two copies would drift, and this one writes credentials. + * + * Partitioned (CHIPS) cookies are counted and skipped, not imported: Electron + * 33's cookie API has no `partitionKey`, so the best we could do is write them + * into the unpartitioned jar, which puts them in the wrong place. Reporting + * "3 skipped" is honest; silently misplacing them is not. + */ +async function applySnapshot( + snapshot: RelaySnapshot, + choice: RelayImportChoice +): Promise { + const result: RelayImportResult = { + cookiesImported: 0, + cookiesFailed: 0, + cookiesSkippedPartitioned: 0, + localStorageImported: 0, + sessionStorageImported: 0, + errors: [] + } + + if (choice.cookies) { + for (const c of snapshot.cookies) { + if (c.partitionKey?.topLevelSite) { + result.cookiesSkippedPartitioned++ + continue + } + const err = await cookies.set(c) + if (err) { + result.cookiesFailed++ + if (result.errors.length < 8) result.errors.push(err) + } else { + result.cookiesImported++ + } + } + } + + if (choice.localStorage && snapshot.localStorage) { + try { + const r = await storage.writeLocalStorage(snapshot.origin, snapshot.localStorage) + result.localStorageImported = r.ok + if (r.blocked) result.errors.push(`${snapshot.origin} blocked localStorage access.`) + else if (r.failed) result.errors.push(`${r.failed} localStorage entries were rejected.`) + } catch (e) { + result.errors.push(e instanceof Error ? e.message : String(e)) + } + } + + if (choice.sessionStorage && snapshot.sessionStorage) { + // Session storage belongs to one tab, so it needs a live tab on that + // origin. Without one there is nothing to write into. + const contents = browser.contentsForOrigin(snapshot.origin) + if (!contents) { + result.errors.push( + `Session storage needs an open tab on ${snapshot.origin}; open it and send again.` + ) + } else { + try { + const r = await storage.writeSessionStorage( + contents, + snapshot.origin, + snapshot.sessionStorage + ) + result.sessionStorageImported = r.ok + } catch (e) { + result.errors.push(e instanceof Error ? e.message : String(e)) + } + } + } + + state.lastTransferAt = Date.now() + return result +} + +/** Remember a completed transfer for the Settings activity list. */ +function record(snapshot: RelaySnapshot, result: RelayImportResult, auto: boolean): void { + state.recent.unshift({ + origin: snapshot.origin, + at: Date.now(), + cookies: result.cookiesImported, + localStorage: result.localStorageImported, + sessionStorage: result.sessionStorageImported, + auto, + error: result.errors[0] + }) + state.recent.length = Math.min(state.recent.length, MAX_RECENT) +} + +/** + * Apply a parked snapshot after the user approved it in the UI. + * + * `trust` marks the origin so future transfers skip the prompt entirely — the + * "approve once, then never again" path. + */ +export async function applyPending( + id: string, + choice: RelayImportChoice, + trust = false +): Promise { + const snapshot = takePending(id) + if (!snapshot) throw new Error('That transfer is no longer waiting for approval.') + + const result = await applySnapshot(snapshot, choice) + record(snapshot, result, false) + if (trust) trustOrigin(snapshot.origin) + broadcast() + return result +} + +/** Discard a parked snapshot without applying any of it. */ +export function rejectPending(id: string): void { + takePending(id) +} + +// --- persistence ------------------------------------------------------------- +// The token is a long-lived credential, so it goes through safeStorage like an +// API key. Pending snapshots are NEVER persisted. + +function persist(): void { + if (!state.pairing) { + repo.setRelayPairing(null) + return + } + const payload = encryptSecret(JSON.stringify(state.pairing)) + repo.setRelayPairing(JSON.stringify(payload)) +} + +/** + * Automation prefs are stored in the CLEAR, unlike the token. + * + * They are a list of hostnames, not a credential — encrypting them would imply + * a protection safeStorage does not provide here, and would make the blocklist + * unreadable (and so unfixable) if the keychain ever changed. + */ +function persistPrefs(): void { + repo.setRelayPrefs(JSON.stringify(state.prefs)) +} + +function load(): void { + const rawPrefs = repo.getRelayPrefs() + if (rawPrefs) { + try { + // Merge over the defaults so a prefs blob written by an older version + // (missing a field we since added) cannot produce `undefined` where the + // code expects an array. + const parsed = JSON.parse(rawPrefs) as Partial + state.prefs = { + autoSend: parsed.autoSend ?? DEFAULT_RELAY_PREFS.autoSend, + trusted: Array.isArray(parsed.trusted) ? parsed.trusted : [], + blocked: Array.isArray(parsed.blocked) ? parsed.blocked : [] + } + } catch { + state.prefs = { ...DEFAULT_RELAY_PREFS } + } + } + + const row = repo.getRelayPairing() + if (!row) return + try { + state.pairing = JSON.parse(decryptSecret(JSON.parse(row))) + } catch { + // Unreadable (keychain changed, DB copied between machines) — drop it and + // make the user re-pair rather than leave a half-broken connection. + repo.setRelayPairing(null) + } +} diff --git a/src/main/services/storage.ts b/src/main/services/storage.ts new file mode 100644 index 0000000..f5d0112 --- /dev/null +++ b/src/main/services/storage.ts @@ -0,0 +1,187 @@ +/** + * Writing Web Storage into the Roxy browser's partition. + * + * Cookies have a first-class Electron API (`session.cookies`). localStorage + * does NOT: there is no `session.setStorage`, and `clearStorageData` can only + * delete. The only supported way to populate an origin's storage is to BE that + * origin — load it in a page on the target partition and assign through the + * real `window.localStorage`. + * + * So this opens a hidden, offscreen window on `persist:roxy-browser`, navigates + * to the origin, writes, and disposes it. Two consequences worth knowing: + * + * - It performs a real navigation to the site. We request `about:blank`-like + * minimal work by aborting the load as soon as the document exists, but the + * origin must be reachable for the browser to grant us a storage context. + * - `sessionStorage` is per browsing-context. Writing it in a throwaway window + * would be pointless — it dies with the window — so session storage is + * applied to a LIVE tab instead (see `applySessionStorage`). + * + * Everything here is origin-scoped and main-only. The renderer never gets a + * `webContents` or an arbitrary `executeJavaScript`, because that would be a + * general-purpose code-execution channel wearing a storage costume. + */ +import { BrowserWindow } from 'electron' +import { PARTITION } from './browser' +import { isImportableOrigin, type RelayStorage } from '../../shared/relay' + +/** How long to wait for the origin to produce a document before giving up. */ +const LOAD_TIMEOUT_MS = 15_000 + +/** + * The script that does the writing, built with the payload inlined as JSON. + * + * Values are injected as a single JSON literal rather than interpolated one by + * one: a cookie/token value containing a quote or a backslash would otherwise + * break out of the string and run as code in the page's own origin. `JSON + * .stringify` of the whole record is exactly one safe literal. + * + * Storage can throw even when present (Safari-style private mode, quota, a + * site's own hardening), so each key is attempted independently and failures + * are counted rather than aborting the batch. + */ +function writerScript(kind: 'localStorage' | 'sessionStorage', data: RelayStorage): string { + return `(() => { + const data = ${JSON.stringify(data)}; + let ok = 0; + let failed = 0; + try { + const store = window.${kind}; + for (const k of Object.keys(data)) { + try { store.setItem(k, data[k]); ok++; } catch { failed++; } + } + } catch { + return { ok: 0, failed: Object.keys(data).length, blocked: true }; + } + return { ok, failed, blocked: false }; + })()` +} + +export interface StorageWriteResult { + ok: number + failed: number + /** The origin denied storage access outright (third-party blocking, etc). */ + blocked: boolean +} + +/** + * Write `data` into `origin`'s localStorage on the browser partition. + * + * Throws on an unusable origin or an unreachable site; per-key failures come + * back in the result instead, since a partial import is still useful. + */ +export async function writeLocalStorage( + origin: string, + data: RelayStorage +): Promise { + if (!isImportableOrigin(origin)) throw new Error(`Cannot write storage for "${origin}".`) + if (!Object.keys(data).length) return { ok: 0, failed: 0, blocked: false } + + const win = new BrowserWindow({ + show: false, + webPreferences: { + partition: PARTITION, + // This window exists to touch one origin's storage. It must not get + // Node, a preload, or any bridge into the app. + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + // Never surface this window; it is machinery, not UI. + offscreen: false + } + }) + + try { + await loadOrigin(win, origin) + return (await win.webContents.executeJavaScript( + writerScript('localStorage', data), + true + )) as StorageWriteResult + } finally { + if (!win.isDestroyed()) win.destroy() + } +} + +/** + * Navigate to `origin` far enough to have a storage context, then stop. + * + * We resolve on `dom-ready` rather than `did-finish-load` so a site with slow + * or hanging subresources doesn't stall the import — the document (and thus + * `window.localStorage`) exists by then. The load is halted immediately after, + * so we don't sit there running the site's scripts. + */ +function loadOrigin(win: BrowserWindow, origin: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject(new Error(`Timed out loading ${origin}.`)) + }, LOAD_TIMEOUT_MS) + + const onReady = (): void => { + cleanup() + // The document exists; we don't need the rest of the page. + if (!win.isDestroyed()) win.webContents.stop() + resolve() + } + const onFail = ( + _e: Electron.Event, + code: number, + desc: string, + _url: string, + isMainFrame: boolean + ): void => { + // Subresource failures are none of our business; only a main-frame + // failure means we never got a document. + if (!isMainFrame) return + // -3 is ERR_ABORTED, which our own stop() triggers. + if (code === -3) return + cleanup() + reject(new Error(`Could not reach ${origin} (${desc}).`)) + } + function cleanup(): void { + clearTimeout(timer) + if (win.isDestroyed()) return + win.webContents.off('dom-ready', onReady) + win.webContents.off('did-fail-load', onFail) + } + + win.webContents.once('dom-ready', onReady) + win.webContents.on('did-fail-load', onFail) + win.loadURL(origin).catch(() => { + // loadURL rejects on abort too; did-fail-load is the authority here. + }) + }) +} + +/** + * Write sessionStorage into a LIVE browsing context. + * + * Unlike localStorage this cannot be done in a throwaway window: session + * storage is scoped to one tab and vanishes with it. The caller passes the + * webContents of a tab already on the target origin; if none is open, session + * storage simply isn't importable and the caller reports that. + */ +export async function writeSessionStorage( + contents: Electron.WebContents, + origin: string, + data: RelayStorage +): Promise { + if (!Object.keys(data).length) return { ok: 0, failed: 0, blocked: false } + // Refuse to write one origin's credentials into a page showing another. + const current = safeOrigin(contents.getURL()) + if (current !== origin) { + throw new Error(`The open tab is on ${current || 'no page'}, not ${origin}.`) + } + return (await contents.executeJavaScript( + writerScript('sessionStorage', data), + true + )) as StorageWriteResult +} + +function safeOrigin(url: string): string { + try { + return new URL(url).origin + } catch { + return '' + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e78fe61..416d2d8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import type { UpdateState } from '../shared/api' import type { CliProxyState } from '../shared/cliproxy' +import type { RelayStatus } from '../shared/relay' /** * The typed bridge exposed to the renderer as `window.roxy`. Every method maps @@ -232,6 +233,25 @@ const roxy: RoxyApi = { clear: (host) => ipcRenderer.invoke(CHANNELS.cookiesClear, host), importJson: (text) => ipcRenderer.invoke(CHANNELS.cookiesImport, text) }, + relay: { + status: () => ipcRenderer.invoke(CHANNELS.relayStatus), + beginPairing: () => ipcRenderer.invoke(CHANNELS.relayBeginPairing), + cancelPairing: () => ipcRenderer.invoke(CHANNELS.relayCancelPairing), + unpair: () => ipcRenderer.invoke(CHANNELS.relayUnpair), + apply: (id, choice, trust) => ipcRenderer.invoke(CHANNELS.relayApply, id, choice, trust), + setPrefs: (prefs) => ipcRenderer.invoke(CHANNELS.relaySetPrefs, prefs), + trustOrigin: (origin) => ipcRenderer.invoke(CHANNELS.relayTrustOrigin, origin), + untrustOrigin: (origin) => ipcRenderer.invoke(CHANNELS.relayUntrustOrigin, origin), + reject: (id) => ipcRenderer.invoke(CHANNELS.relayReject, id), + installExtension: () => ipcRenderer.invoke(CHANNELS.relayInstallExtension), + revealExtension: () => ipcRenderer.invoke(CHANNELS.relayRevealExtension), + onState: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, status: RelayStatus): void => + callback(status) + ipcRenderer.on(CHANNELS.relayState, handler) + return () => ipcRenderer.removeListener(CHANNELS.relayState, handler) + } + }, services: { list: (sessionId) => ipcRenderer.invoke(CHANNELS.servicesList, sessionId), output: (sessionId, id) => ipcRenderer.invoke(CHANNELS.servicesOutput, sessionId, id), diff --git a/src/renderer/src/components/SessionRelay.tsx b/src/renderer/src/components/SessionRelay.tsx new file mode 100644 index 0000000..399a3c9 --- /dev/null +++ b/src/renderer/src/components/SessionRelay.tsx @@ -0,0 +1,771 @@ +import { useCallback, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { + Check, + Globe, + Copy, + ExternalLink, + FolderOpen, + Loader2, + RefreshCw, + ShieldAlert, + X +} from 'lucide-react' +import type { PendingSnapshot, RelayImportResult, RelayStatus } from '@shared/relay' +import { api } from '../lib/api' +import { cn } from '../lib/cn' + +/** + * Session Relay — the Settings surface. + * + * Two jobs: walk the user through installing the browser extension (which is + * genuinely four manual steps in a browser we don't control), and be the place + * a queued transfer gets approved. + * + * The approval prompt shows COUNTS, never values. Main deliberately keeps the + * credentials until the user says yes, so there is nothing here to leak. + */ +export function SessionRelay(): JSX.Element { + const [status, setStatus] = useState(null) + const [open, setOpen] = useState(false) + + const refresh = useCallback(async () => setStatus(await api.relay.status()), []) + + useEffect(() => { + void refresh() + // Main pushes on every state change (pairing, heartbeat, queued snapshot), + // so the panel is live without polling. + return api.relay.onState(setStatus) + }, [refresh]) + + const pending = status?.pending ?? [] + + return ( +
+
+
+
+
Session Relay
+ {status?.paired && ( + + + {status.browser ?? 'Browser'} connected + + )} +
+

+ Send a site's live session from Chrome, Edge or Brave into Roxy's browser, so + you can debug a signed-in page without signing in again. Nothing transfers until you + click send in the extension, and nothing is applied until you approve it here. +

+
+ +
+ + {pending.length > 0 && ( +
+ {pending.map((p) => ( + + ))} +
+ )} + + {open && ( + setOpen(false)} + onChanged={() => void refresh()} + /> + )} +
+ ) +} + +/** One queued transfer, awaiting yes/no. */ +function PendingCard({ snapshot }: { snapshot: PendingSnapshot }): JSX.Element { + const [choice, setChoice] = useState({ + cookies: true, + localStorage: true, + sessionStorage: snapshot.sessionStorageCount > 0 + }) + const [busy, setBusy] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + // Ticked by default: someone approving a transfer almost always wants the + // next one to be silent. It is the whole point of the feature, and it stays + // reversible from the trusted-sites list. + const [trust, setTrust] = useState(true) + + const apply = async (): Promise => { + setBusy(true) + setError(null) + try { + setResult(await api.relay.apply(snapshot.id, choice, trust)) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + if (result) { + const bits = [ + result.cookiesImported ? `${result.cookiesImported} cookies` : null, + result.localStorageImported ? `${result.localStorageImported} localStorage` : null, + result.sessionStorageImported ? `${result.sessionStorageImported} sessionStorage` : null + ].filter(Boolean) + return ( +
+
+ + Imported {bits.length ? bits.join(', ') : 'nothing'} for {snapshot.origin}. +
+ {result.cookiesSkippedPartitioned > 0 && ( +

+ {result.cookiesSkippedPartitioned} partitioned{' '} + {result.cookiesSkippedPartitioned === 1 ? 'cookie was' : 'cookies were'} skipped — + Electron cannot store these in the right partition, so importing them would put them in + the wrong one. +

+ )} + {result.errors.map((e, i) => ( +

+ {e} +

+ ))} +
+ ) + } + + return ( +
+
+ +
+
+ {snapshot.browser} wants to send a session to Roxy +
+
+ {snapshot.origin} +
+
+
+ +
+ setChoice((c) => ({ ...c, cookies: v }))} + /> + setChoice((c) => ({ ...c, localStorage: v }))} + /> + setChoice((c) => ({ ...c, sessionStorage: v }))} + /> +
+ +

+ Values are hidden and stay in Roxy's main process until you import. +

+ + + + {error &&

{error}

} + +
+ + +
+
+ ) +} + +function Pick({ + label, + count, + checked, + onChange +}: { + label: string + count: number + checked: boolean + onChange: (v: boolean) => void +}): JSX.Element { + return ( + + ) +} + +/** Which browser's extensions page to send the user to. */ +const BROWSERS = [ + { name: 'Chrome', url: 'chrome://extensions' }, + { name: 'Edge', url: 'edge://extensions' }, + { name: 'Brave', url: 'brave://extensions' } +] as const + +/** + * The four-step install, plus pairing. + * + * The steps are manual because loading an unpacked extension is: we cannot + * click through another browser's UI. So the job here is to make each step + * unambiguous and to do the parts we CAN automate (copying the folder, + * revealing it, minting the code). + */ +function SetupDialog({ + status, + onClose, + onChanged +}: { + status: RelayStatus | null + onClose: () => void + onChanged: () => void +}): JSX.Element { + const [installed, setInstalled] = useState<{ path: string; version: string } | null>(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [pairing, setPairing] = useState<{ code: string; expiresAt: number } | null>(null) + const [copied, setCopied] = useState(false) + + const paired = status?.paired ?? false + + const install = async (): Promise => { + setBusy(true) + setError(null) + try { + setInstalled(await api.relay.installExtension()) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + const startPairing = async (): Promise => { + setError(null) + try { + const r = await api.relay.beginPairing() + setPairing({ code: r.code, expiresAt: r.expiresAt }) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + // The code is short-lived; stop showing a dead one. + const [now, setNow] = useState(Date.now()) + useEffect(() => { + if (!pairing) return + const t = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(t) + }, [pairing]) + const secondsLeft = pairing ? Math.max(0, Math.ceil((pairing.expiresAt - now) / 1000)) : 0 + useEffect(() => { + if (pairing && secondsLeft === 0) setPairing(null) + }, [pairing, secondsLeft]) + + // Pairing completing is pushed from main; close the code once it lands. + useEffect(() => { + if (paired && pairing) setPairing(null) + }, [paired, pairing]) + + // Closing by any route (Escape, scrim, the X) should drop a live pairing + // code: it is short-lived and single-purpose, so leaving one valid after the + // user walked away is needless exposure. + const close = useCallback((): void => { + if (pairing) void api.relay.cancelPairing() + onClose() + }, [onClose, pairing]) + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [close]) + + const relayNotListening = status && !status.listening + + // Portal to : this dialog is rendered from INSIDE the Session Relay + // card, which is `overflow-hidden` to clip its rounded corners — without the + // portal the dialog is clipped to that card. It also makes the dialog immune + // to any future ancestor with `overflow`, `transform` or `filter`, all of + // which would otherwise trap a `position: fixed` child. + return createPortal( +
+
e.stopPropagation()} + > +
+ +
+ {paired ? 'Session Relay' : 'Connect a browser'} +
+ +
+ + {relayNotListening && ( +
+ The relay could not open port {status?.port}. Another app is probably using it — close + it and restart Roxy. +
+ )} + + {paired ? ( + + ) : ( +
+ +

+ Roxy copies it to your Documents folder, where your browser can load it. +

+
+ void install()} busy={busy} primary={!installed}> + {installed ? 'Save again' : 'Save extension'} + + {installed && ( + void api.relay.revealExtension()}> + Show folder + + )} +
+ {installed && ( +
+ + {installed.path} + + +
+ )} +
+ + +
+ {BROWSERS.map((b) => ( + void navigator.clipboard.writeText(b.url)}> + Copy {b.name} URL + + ))} +
+

+ Browsers block other apps from opening chrome:// pages, so paste the + copied address into your browser's address bar. +

+
+ + +

+ The toggle is in the top-right of that page. It is what allows an extension to be + loaded from a folder. +

+
+ + +

+ Click Load unpacked and choose + the folder from step 1. The Roxy icon appears in your toolbar. +

+
+ + + {pairing ? ( +
+

+ Click the Roxy icon in your browser and enter this code: +

+
+
+ {pairing.code} +
+
+ Expires in {secondsLeft}s + +
+
+
+ ) : ( + void startPairing()} primary> + Show pairing code + + )} +
+ + {error &&

{error}

} +
+ )} +
+
, + document.body + ) +} + +function ManageBody({ + status, + onChanged, + onClose +}: { + status: RelayStatus | null + onChanged: () => void + onClose: () => void +}): JSX.Element { + const last = status?.lastTransferAt + const seen = status?.lastSeenAt + const fmt = (t?: number): string => (t ? new Date(t).toLocaleString() : 'never') + + return ( +
+
+ + + + + + +
+ +

+ Transfers only happen when you click send in the extension, and only after you approve them + here. +

+ +
+ { + await api.relay.installExtension() + onChanged() + }} + > + Update extension files + + void api.relay.revealExtension()}> + Show folder + + { + await api.relay.unpair() + onChanged() + onClose() + }} + > + Disconnect + +
+ + +
+ ) +} + +/** + * The hands-off settings: master switch, the sites that skip the prompt, and + * the blocklist that overrides both. + */ +function Automation({ status }: { status: RelayStatus | null }): JSX.Element { + const prefs = status?.prefs + const [blockDraft, setBlockDraft] = useState('') + const [error, setError] = useState(null) + + const addBlock = async (): Promise => { + const v = blockDraft.trim() + if (!v || !prefs) return + setError(null) + try { + await api.relay.setPrefs({ blocked: [...prefs.blocked, v] }) + setBlockDraft('') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + if (!prefs) return <> + + return ( +
+ + + {/* Trusted */} +
+
+ Trusted sites ({prefs.trusted.length}) +
+ {prefs.trusted.length === 0 ? ( +

+ None yet. Tick “Always allow” when you approve a transfer. +

+ ) : ( +
    + {prefs.trusted.map((o) => ( +
  • + + {o} + + +
  • + ))} +
+ )} +
+ + {/* Blocked */} +
+
+ Never transfer +
+

+ Covers the domain and all its subdomains, and overrides trust. Enforced by Roxy as well as + the extension. +

+
+ setBlockDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void addBlock() + }} + placeholder="bank.com" + spellCheck={false} + className="h-7 min-w-0 flex-1 rounded-md border border-border bg-surface-2 px-2 font-mono text-[11px] text-text outline-none placeholder:text-text-subtle focus:border-accent" + /> + void addBlock()}>Add +
+ {error &&

{error}

} + {prefs.blocked.length > 0 && ( +
    + {prefs.blocked.map((b) => ( +
  • + + {b} + + +
  • + ))} +
+ )} +
+ + {/* Activity — the audit trail for transfers that happened with no prompt. */} + {status.recent.length > 0 && ( +
+
+ Recent transfers +
+
    + {status.recent.slice(0, 8).map((t, i) => ( +
  • + + {t.origin} + + + {t.cookies}c{t.localStorage ? ` · ${t.localStorage}ls` : ''} + {t.auto ? ' · auto' : ''} + + + {new Date(t.at).toLocaleTimeString()} + +
  • + ))} +
+
+ )} +
+ ) +} + +function Row({ + label, + value, + mono +}: { + label: string + value: string + mono?: boolean +}): JSX.Element { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +function Step({ + n, + title, + children, + last +}: { + n: number + title: string + children: React.ReactNode + last?: boolean +}): JSX.Element { + return ( +
+
+
+ {n} +
+ {!last &&
} +
+
+
{title}
+
{children}
+
+
+ ) +} + +function Btn({ + children, + onClick, + primary, + danger, + busy +}: { + children: React.ReactNode + onClick: () => void + primary?: boolean + danger?: boolean + busy?: boolean +}): JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/routes/Settings.tsx b/src/renderer/src/routes/Settings.tsx index de63467..5e660da 100644 --- a/src/renderer/src/routes/Settings.tsx +++ b/src/renderer/src/routes/Settings.tsx @@ -18,6 +18,7 @@ import { randomSlug, slugToBranchSegment } from '@shared/slugs' import { PageShell } from '../components/PageShell' import { McpServers } from '../components/McpServers' import { CookiePanel } from '../components/CookiePanel' +import { SessionRelay } from '../components/SessionRelay' import { ConfigBackup } from '../components/ConfigBackup' import { ActivitySection } from '../components/ActivitySection' import { ProviderLogo } from '../lib/providerLogos' @@ -300,6 +301,10 @@ export default function Settings(): JSX.Element {
+ +
+ +
diff --git a/src/shared/api.ts b/src/shared/api.ts index 2bca98d..fdc3f68 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -2,6 +2,7 @@ * The typed contract exposed to the renderer as `window.roxy`. * Implemented in src/preload/index.ts, handled in src/main/ipc/*. */ +import type { RelayImportChoice, RelayImportResult, RelayPrefs, RelayStatus } from './relay' import type { AddMessageInput, AppSettings, @@ -898,6 +899,36 @@ export interface RoxyApi { /** Import a Cookie-Editor / EditThisCookie JSON blob. Rejects only on malformed JSON. */ importJson(text: string): Promise } + /** + * Session Relay - a paired browser extension that hands a site's live + * session (cookies + storage) to Roxy. Nothing is applied without the user + * confirming the specific transfer; see shared/relay.ts for the threat model. + */ + relay: { + status(): Promise + /** Mint a pairing code for the user to type into the extension. */ + beginPairing(): Promise<{ code: string; expiresAt: number; port: number }> + cancelPairing(): Promise + /** Revoke the paired extension's token. */ + unpair(): Promise + /** + * Apply a queued snapshot. This is the only path that writes it. + * `trust` also marks the origin, so future transfers skip the prompt. + */ + apply(id: string, choice: RelayImportChoice, trust?: boolean): Promise + /** Update automation settings (auto-send switch, trusted list, blocklist). */ + setPrefs(prefs: Partial): Promise + /** Mark an origin as needing no further confirmation. */ + trustOrigin(origin: string): Promise + untrustOrigin(origin: string): Promise + /** Discard a queued snapshot untouched. */ + reject(id: string): Promise + /** Copy the bundled extension to Documents; returns where it landed. */ + installExtension(): Promise<{ path: string; version: string }> + revealExtension(): Promise + /** Subscribe to relay status; returns an unsubscribe fn. */ + onState(callback: (status: RelayStatus) => void): () => void + } services: { /** Background processes owned by a session (includes its subagents'). */ list(sessionId: string): Promise diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 743e775..218f783 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -212,6 +212,24 @@ export const CHANNELS = { */ browserChromeHeight: 'browser:chrome-height', + /** + * Session Relay - the paired browser extension that transfers a site's + * cookies/storage into Roxy. See shared/relay.ts for the protocol. + */ + relayStatus: 'relay:status', + relayBeginPairing: 'relay:begin-pairing', + relayCancelPairing: 'relay:cancel-pairing', + relayUnpair: 'relay:unpair', + relayApply: 'relay:apply', + relayReject: 'relay:reject', + relayInstallExtension: 'relay:install-extension', + relayRevealExtension: 'relay:reveal-extension', + relaySetPrefs: 'relay:set-prefs', + relayTrustOrigin: 'relay:trust-origin', + relayUntrustOrigin: 'relay:untrust-origin', + /** main -> renderer: relay status changed (paired, snapshot queued, ...) */ + relayState: 'relay:state', + /** renderer -> main: a session's background processes (the Services panel) */ servicesList: 'services:list', /** renderer -> main: full buffered output of one service, for the log view */ diff --git a/src/shared/relay.ts b/src/shared/relay.ts new file mode 100644 index 0000000..5684bb4 --- /dev/null +++ b/src/shared/relay.ts @@ -0,0 +1,316 @@ +/** + * The Session Relay wire protocol — shared by the Electron main process, the + * renderer, and the bundled Chrome extension. + * + * The relay moves a site's session (cookies + origin storage) from a real + * browser into Roxy's browser partition, so you can debug a signed-in site + * without signing in again. It is deliberately ONE-WAY (browser -> Roxy). + * + * CONSENT MODEL. A site starts at zero access: the extension holds no host + * permission for it, so it cannot read its cookies at all. The first transfer + * is fully manual (click in the extension, approve in Roxy). At that point the + * user may mark the origin TRUSTED, after which sends and imports for THAT + * ORIGIN happen with no further clicks. + * + * The lists are deliberately asymmetric, because they fail in opposite + * directions: + * + * - TRUST is exact-origin and opt-in. `https://app.example.com` never + * implies `https://admin.example.com`. Broad trust would be a footgun, and + * Chrome enforces the same shape anyway: auto-send can only ever reach an + * origin the user separately granted host access to. + * - BLOCKS match broadly and win over everything. `example.com` blocks the + * apex and every subdomain, is checked before trust, and is enforced in + * BOTH the extension and Roxy — so a stale unpacked extension cannot + * relay a blocked site even if its cached prefs are out of date. + * + * THREAT MODEL. A snapshot is live credentials. The relay listens on loopback, + * which any process on the machine — and, via a form POST or an tag, any + * WEBSITE the user visits — can also reach. So the endpoint is not "local + * therefore trusted". Every request must prove three things: + * + * 1. WHO: a bearer token issued during an explicit pairing handshake, held in + * the extension's own storage. Random web pages don't have it. + * 2. WHERE FROM: an `Origin` of exactly `chrome-extension://`. + * Browsers set `Origin` themselves and forbid pages from forging it, so + * this alone rejects every `https://evil.com` request even if the token + * somehow leaked. + * 3. WHICH HOST: a `Host` header of `127.0.0.1:`. Without this, an + * attacker who controls DNS can point `evil.com` at 127.0.0.1 and have the + * browser treat the relay as same-origin (DNS rebinding). + * + * Requests are also JSON-only with a required custom header, so they can never + * be a simple/no-preflight cross-origin request from a page. + * + * WHY NOT NATIVE MESSAGING (yet). `chrome.runtime.connectNative` is a stronger + * transport — no listening socket at all — but it needs a registered host + * manifest plus a Windows registry key or a per-browser profile path on + * macOS/Linux, and a separate stdio executable we'd have to ship and sign. The + * loopback endpoint below reaches the same place with checks a reviewer can + * read in one file. The extension talks to a small module boundary, so the + * transport can be swapped later without touching the popup or this schema. + */ + +/** Bumped when the snapshot shape changes incompatibly. */ +export const RELAY_PROTOCOL_VERSION = 1 + +/** + * The loopback port. Fixed so the extension can find Roxy without discovery + * (an extension cannot read a file to learn a random port). Chosen well above + * the dev-server range in `ports.ts` (3100-3999) so it never collides with a + * session's own server. + */ +export const RELAY_PORT = 4317 + +/** + * Required on every relay request. Custom headers force a CORS preflight, so a + * web page cannot reach the relay with a "simple" request that skips one — and + * our preflight only ever approves the paired extension's origin. + */ +export const RELAY_HEADER = 'x-roxy-relay' + +/** How long a pairing code is valid. Short: it's read off a screen and typed. */ +export const PAIRING_TTL_MS = 3 * 60 * 1000 + +/** Hard cap on a snapshot body. Generous for cookies, far below a memory risk. */ +export const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024 + +/** + * The bundled extension's ID. + * + * Fixed by the `key` field in its manifest.json, which pins the ID even when + * the extension is loaded unpacked — without it Chrome derives an ID from the + * install path, so every reinstall would produce a different origin and break + * the pairing. Roxy authorizes exactly this one `chrome-extension://` origin. + * + * Derived as Chrome does: sha256 of the DER public key, first 16 bytes, each + * nibble mapped 0-15 -> a-p. If the manifest key ever changes, this must too. + */ +export const RELAY_EXTENSION_ID = 'bekpajpbgjeloofgicpnkgahfllakeao' + +/** One cookie as the extension sees it (`chrome.cookies.Cookie`). */ +export interface RelayCookie { + name: string + value: string + domain: string + path: string + secure: boolean + httpOnly: boolean + hostOnly: boolean + session: boolean + sameSite: 'no_restriction' | 'lax' | 'strict' | 'unspecified' + /** Seconds since epoch; absent for session cookies. */ + expirationDate?: number + /** + * Set when the cookie is partitioned (CHIPS). Carried so we can TELL the user + * it was skipped — Electron 33's cookie API has no `partitionKey` field, so + * such a cookie cannot be reproduced faithfully. Importing it unpartitioned + * would put it in the wrong jar, which is worse than not importing it. + */ + partitionKey?: { topLevelSite?: string; hasCrossSiteAncestor?: boolean } +} + +/** Key/value pairs lifted from one origin's Web Storage. */ +export type RelayStorage = Record + +/** What the extension sends after the user presses "Send session". */ +export interface RelaySnapshot { + v: typeof RELAY_PROTOCOL_VERSION + /** The exact origin the storage belongs to, e.g. `https://app.example.com`. */ + origin: string + /** Page title at capture time, purely to make the Roxy prompt legible. */ + title?: string + capturedAt: number + cookies: RelayCookie[] + /** Absent when the user unticked it, or the page blocked access. */ + localStorage?: RelayStorage + sessionStorage?: RelayStorage +} + +/** A snapshot held in main, awaiting the user's confirmation in the UI. */ +export interface PendingSnapshot { + id: string + origin: string + title?: string + receivedAt: number + /** Which browser sent it, for the prompt ("Chrome wants to send…"). */ + browser: string + cookieCount: number + /** Partitioned cookies, counted separately: they are reported, not applied. */ + partitionedCookieCount: number + localStorageCount: number + sessionStorageCount: number + /** + * Byte size of the values, so the UI can say "12 cookies, 4 KB" without ever + * shipping the values themselves to the renderer. Credentials stay in main + * until the user approves the import. + */ + approxBytes: number +} + +/** Connection state, as the Settings UI renders it. */ +export interface RelayStatus { + /** The loopback listener is up. */ + listening: boolean + port: number + /** An extension has completed pairing and holds a live token. */ + paired: boolean + /** The paired extension id, shown in the manage screen. */ + extensionId?: string + /** Which browser paired, self-reported at pairing time. */ + browser?: string + extensionVersion?: string + lastSeenAt?: number + lastTransferAt?: number + /** A pairing code is on screen right now, and this is when it expires. */ + pairingExpiresAt?: number + /** Snapshots waiting on the user's yes/no. */ + pending: PendingSnapshot[] + /** Automation settings (trusted origins, blocklist, master switch). */ + prefs: RelayPrefs + /** The most recent transfers, newest first, for the activity list. */ + recent: RelayTransfer[] +} + +/** What the user chose to apply from a pending snapshot. */ +export interface RelayImportChoice { + cookies: boolean + localStorage: boolean + sessionStorage: boolean +} + +/** Outcome of applying a snapshot. */ +export interface RelayImportResult { + cookiesImported: number + cookiesFailed: number + /** Partitioned cookies deliberately not applied (see `RelayCookie`). */ + cookiesSkippedPartitioned: number + localStorageImported: number + sessionStorageImported: number + errors: string[] +} + +/** + * Whether an origin can receive storage. Storage is written by loading the + * origin in a hidden page and touching `window.localStorage`, which only exists + * for http/https — `file:`, `data:` and extension pages have no usable, + * addressable Web Storage for our purposes. + */ +export function isImportableOrigin(origin: string): boolean { + try { + const u = new URL(origin) + return (u.protocol === 'https:' || u.protocol === 'http:') && u.origin === origin + } catch { + return false + } +} + +/** + * Automation preferences. Owned by Roxy; the extension caches a copy and + * refreshes it on every heartbeat, so Roxy stays the single source of truth. + */ +export interface RelayPrefs { + /** Master switch. Off means every transfer is manual, as before. */ + autoSend: boolean + /** + * Origins that send and import without prompting. EXACT origins only — + * scheme, host and port must all match. + */ + trusted: string[] + /** + * Host patterns that may never be relayed, whatever else is set. Matches the + * apex and all subdomains; see `isBlockedHost`. + */ + blocked: string[] +} + +export const DEFAULT_RELAY_PREFS: RelayPrefs = { + autoSend: true, + trusted: [], + // Empty by default. A shipped list would be both presumptuous and dangerously + // incomplete — it reads as "we protected you", when the real protection is + // that nothing is trusted until you say so. + blocked: [] +} + +/** + * Minimum gap between automatic sends for one origin. + * + * Auto-send is driven partly by `chrome.cookies.onChanged`, which fires + * constantly on a busy site — every analytics ping can rewrite a cookie. This + * both stops a redundant flood and is what makes the token-refresh case work: + * the new token arrives shortly after it rotates, not 200 times a minute. + */ +export const AUTO_SEND_COOLDOWN_MS = 15_000 + +/** + * Normalize a blocklist entry to a bare host. + * + * Users paste whatever is in their address bar, so tolerate a scheme, a port, + * a path, a `*.` prefix and a trailing dot. Everything reduces to the host, + * which is the only part matching operates on. + */ +function normalizePattern(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/^\*\./, '') + .split('/')[0] + .replace(/:\d+$/, '') + .replace(/\.$/, '') +} + +/** + * Does `host` fall under any blocklist pattern? + * + * Matching is suffix-anchored on a DOT BOUNDARY, which is what stops the two + * classic bypasses: `example.com` must not match `notexample.com` (no boundary) + * and must not match `example.com.evil.net` (not a suffix). Blocking is + * deliberately broad — an entry covers the apex and every subdomain — because + * for a safety net over-matching is the safe direction to err. + */ +export function isBlockedHost(host: string, patterns: string[]): boolean { + const h = host.trim().toLowerCase().replace(/\.$/, '') + if (!h) return true // unparseable host: refuse rather than guess + for (const raw of patterns) { + const p = normalizePattern(raw) + if (!p) continue + if (h === p || h.endsWith(`.${p}`)) return true + } + return false +} + +/** Convenience: block check straight from an origin string. */ +export function isBlockedOrigin(origin: string, patterns: string[]): boolean { + try { + return isBlockedHost(new URL(origin).hostname, patterns) + } catch { + return true // if we cannot parse it, we cannot clear it + } +} + +/** + * May this origin be relayed without asking? + * + * Blocks are evaluated FIRST and cannot be overridden by trust — adding a + * domain to the blocklist must be sufficient on its own, without also hunting + * through the trusted list to revoke it. + */ +export function isAutoAllowed(origin: string, prefs: RelayPrefs): boolean { + if (!prefs.autoSend) return false + if (isBlockedOrigin(origin, prefs.blocked)) return false + return prefs.trusted.includes(origin) +} + +/** One completed automatic transfer, for the activity list in Settings. */ +export interface RelayTransfer { + origin: string + at: number + cookies: number + localStorage: number + sessionStorage: number + /** Set when the transfer was applied without prompting. */ + auto: boolean + error?: string +} diff --git a/test/relay-e2e.ts b/test/relay-e2e.ts new file mode 100644 index 0000000..4210f6c --- /dev/null +++ b/test/relay-e2e.ts @@ -0,0 +1,294 @@ +/** + * Session Relay — end-to-end check against a REAL Chrome. + * + * The in-process suite (test/relay.ts) proves the server refuses the attacks. + * This proves the other half: that the actual bundled extension loads in a real + * Chrome, pairs over the real protocol, and lands a snapshot in Roxy's queue. + * Between them, both sides of the wire are covered by real code. + * + * It runs the relay in THIS process (so it can mint a pairing code directly, + * exactly as Settings does) and drives Chrome over the DevTools Protocol. + * + * WHY CDP AND NOT `--load-extension`: Chrome 137+ removed that flag from + * branded builds because malware abused it, and the escape-hatch feature flag + * has since been removed too. `Extensions.loadUnpacked` is the documented + * replacement. The USER flow is unaffected — "Load unpacked" on + * chrome://extensions with Developer mode on, which is what the setup wizard + * walks through, still works. This is a harness limitation, not a product one. + * + * Not part of `npm run smoke`: it needs a Chrome install and drives a real + * browser, which is too environment-dependent for CI. + * + * Run: npm run e2e:relay + */ +import { app } from 'electron' +import { spawn } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import * as relay from '../src/main/services/relay' +import { DEFAULT_RELAY_PREFS, RELAY_EXTENSION_ID } from '../src/shared/relay' + +const CDP_PORT = 9333 +const EXT = resolve('resources/session-relay') + +const CHROME = [ + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome' +].find((p) => existsSync(p)) + +let failures = 0 +function check(name: string, cond: boolean, detail: unknown = ''): void { + const line = cond ? ` ok ${name}` : ` FAIL ${name} ${detail === '' ? '' : String(detail)}` + if (!cond) failures++ + process.stderr.write(line + '\n') +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +/** + * Minimal CDP client, over the `ws` package (already a direct dependency). + * + * Not the platform WebSocket: Electron's MAIN process has no global one, and + * routing through a hidden renderer does not work either — an `about:blank` + * page has an opaque origin, and Chrome refuses CDP socket upgrades from it. + */ +async function cdp(wsUrl: string): Promise<{ + send: (method: string, params?: unknown, sessionId?: string) => Promise> + close: () => void +}> { + const { WebSocket } = await import('ws') + const ws = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 }) + await new Promise((res, rej) => { + ws.once('open', res) + ws.once('error', rej) + }) + + let id = 0 + const waiting = new Map void; rej: (e: Error) => void }>() + ws.on('message', (data) => { + const msg = JSON.parse(String(data)) + const w = waiting.get(msg.id) + if (!w) return + waiting.delete(msg.id) + if (msg.error) w.rej(new Error(msg.error.message)) + else w.res((msg.result ?? {}) as never) + }) + + return { + send: (method, params = {}, sessionId) => + new Promise((res, rej) => { + const n = ++id + waiting.set(n, { res: res as (v: never) => void, rej }) + const frame: Record = { id: n, method, params } + if (sessionId) frame.sessionId = sessionId + ws.send(JSON.stringify(frame)) + // loadUnpacked installs an extension; give it room, but never hang. + setTimeout(() => { + if (waiting.delete(n)) rej(new Error(`CDP ${method} timed out`)) + }, 60_000) + }), + close: () => ws.close() + } +} + +async function main(): Promise { + await app.whenReady() + process.stderr.write('session relay e2e:\n') + + if (!CHROME) { + process.stderr.write(' no Chrome installed; skipping.\n') + app.exit(0) + return + } + + await relay.start() + check('relay is listening', relay.status().listening) + // Start from a clean slate so a previous run's pairing can't mask a failure. + relay.unpair() + + const profile = mkdtempSync(join(tmpdir(), 'roxy-relay-')) + const chrome = spawn( + CHROME, + [ + `--user-data-dir=${profile}`, + `--remote-debugging-port=${CDP_PORT}`, + '--no-first-run', + '--no-default-browser-check', + 'about:blank' + ], + { stdio: 'ignore' } + ) + + try { + let version: { Browser: string; webSocketDebuggerUrl: string } | null = null + for (let i = 0; i < 40 && !version; i++) { + await sleep(500) + version = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`) + .then((r) => r.json()) + .catch(() => null) + } + if (!version) throw new Error('Chrome DevTools never came up.') + process.stderr.write(` (${version.Browser})\n`) + + const client = await cdp(version.webSocketDebuggerUrl) + const loaded = (await client.send('Extensions.loadUnpacked', { path: EXT })) as unknown as { + id?: string + } + check('the bundled extension loads in real Chrome', Boolean(loaded?.id), JSON.stringify(loaded)) + check(' and takes the ID we authorize', loaded?.id === RELAY_EXTENSION_ID, loaded?.id) + + await sleep(2000) + const targets = (await fetch(`http://127.0.0.1:${CDP_PORT}/json/list`).then((r) => + r.json() + )) as { id: string; type: string; url: string }[] + const sw = targets.find( + (t) => t.type === 'service_worker' && t.url.includes(RELAY_EXTENSION_ID) + ) + check('its service worker starts', Boolean(sw)) + if (!sw) throw new Error('no service worker to drive') + + const sessionId = ( + (await client.send('Target.attachToTarget', { + targetId: sw.id, + flatten: true + })) as unknown as { sessionId: string } + ).sessionId + + /** Run an expression inside the extension's service worker. */ + const inSw = async (expression: string): Promise => { + const r = (await client.send( + 'Runtime.evaluate', + { expression, awaitPromise: true, returnByValue: true }, + sessionId + )) as unknown as { exceptionDetails?: { text: string }; result: { value: unknown } } + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value + } + + // Everything below goes through the extension's OWN message handlers, so + // this exercises the shipped background.js, not a re-implementation. + const bad = (await inSw(`__roxyRelay.pair('000000')`)) as { + ok?: boolean + } + check('a wrong pairing code is refused', bad?.ok === false, JSON.stringify(bad)) + check(' and nothing is paired', !relay.status().paired) + + const { code } = relay.beginPairing() + const paired = (await inSw(`__roxyRelay.pair('${code}')`)) as { ok?: boolean } + check('the real code pairs', paired?.ok === true, JSON.stringify(paired)) + check(' Roxy now reports paired', relay.status().paired) + check(' as the extension we pinned', relay.status().extensionId === RELAY_EXTENSION_ID) + + const status = (await inSw(`__roxyRelay.getToken().then(t => ({ paired: Boolean(t) }))`)) as { + paired?: boolean + } + check(' and the extension agrees', status?.paired === true) + + const sent = (await inSw(`__roxyRelay.sendSnapshot({ + v: 1, + origin: 'https://example.com', + capturedAt: Date.now(), + cookies: [{ + name: 'e2e', value: 'from-chrome', domain: '.example.com', path: '/', + secure: true, httpOnly: false, hostOnly: false, session: true, sameSite: 'lax' + }] + })`)) as { ok?: boolean; error?: string } + check('a snapshot reaches Roxy', sent?.ok === true, JSON.stringify(sent)) + + const pending = relay.status().pending + check(' it is QUEUED, not applied', pending.length === 1, pending.length) + check(' with the right origin', pending[0]?.origin === 'https://example.com') + check( + ' and its value never reaches status', + !JSON.stringify(relay.status()).includes('from-chrome') + ) + + // Disconnecting must cut the extension off immediately. + relay.unpair() + const after = (await inSw( + `__roxyRelay.sendSnapshot({ v: 1, origin: 'https://example.com', capturedAt: Date.now(), cookies: [] })` + )) as { ok?: boolean } + check('after Disconnect the extension is refused', after?.ok === false, JSON.stringify(after)) + + // ---- the no-clicks path ------------------------------------------------ + // Re-pair, trust the origin, and assert a snapshot is APPLIED on arrival + // rather than queued. This is the behaviour the feature exists for. + const { code: code2 } = relay.beginPairing() + await inSw(`__roxyRelay.pair('${code2}')`) + relay.setPrefs({ autoSend: true, trusted: ['https://example.com'], blocked: [] }) + + const auto = (await inSw(`__roxyRelay.sendSnapshot({ + v: 1, + origin: 'https://example.com', + capturedAt: Date.now(), + cookies: [{ + name: 'auto', value: 'no-clicks', domain: '.example.com', path: '/', + secure: true, httpOnly: false, hostOnly: false, session: true, sameSite: 'lax' + }] + })`)) as { ok?: boolean } + check('a trusted origin transfers with no prompt', auto?.ok === true, JSON.stringify(auto)) + check(' nothing was queued for approval', relay.status().pending.length === 0) + check(' and it is recorded as automatic', relay.status().recent[0]?.auto === true) + check( + ' with the cookie actually applied', + relay.status().recent[0]?.cookies === 1, + JSON.stringify(relay.status().recent[0]) + ) + + // The cooldown must stop a chatty site from re-sending in a loop. + const again = (await inSw(`__roxyRelay.sendSnapshot({ + v: 1, origin: 'https://example.com', capturedAt: Date.now(), + cookies: [{ + name: 'auto', value: 'again', domain: '.example.com', path: '/', + secure: true, httpOnly: false, hostOnly: false, session: true, sameSite: 'lax' + }] + })`)) as { ok?: boolean } + check('an immediate repeat is throttled, not reapplied', again?.ok === true) + check(' still only one recorded transfer', relay.status().recent.length === 1) + + // A blocked domain must be refused even though the origin is trusted. + relay.setPrefs({ blocked: ['example.com'] }) + const blocked = (await inSw(`__roxyRelay.sendSnapshot({ + v: 1, origin: 'https://example.com', capturedAt: Date.now(), cookies: [] + })`)) as { ok?: boolean; error?: string } + check('the blocklist refuses a trusted origin', blocked?.ok === false, JSON.stringify(blocked)) + check(' and trust was revoked by the block', relay.status().prefs.trusted.length === 0) + + // The extension must learn about the block from the heartbeat, so it stops + // even trying — the server check is a backstop, not the only guard. + await inSw(`__roxyRelay.heartbeat()`) + const cached = (await inSw(`JSON.stringify(__roxyRelay.prefs())`)) as string + check('the extension picked up the blocklist', cached.includes('example.com'), cached) + + relay.setPrefs({ ...DEFAULT_RELAY_PREFS }) + + client.close() + } finally { + chrome.kill() + relay.stop() + try { + rmSync(profile, { recursive: true, force: true }) + } catch { + // Chrome holds the profile briefly after kill; harmless in a temp dir. + } + } + + process.stderr.write( + failures ? `\nE2E FAILED — ${failures} failing\n` : '\nAll relay e2e checks passed.\n' + ) + app.exit(failures ? 1 : 0) +} + +process.on('uncaughtException', (e) => { + process.stderr.write(`CRASH: ${e?.stack ?? e}\n`) + app.exit(1) +}) +process.on('unhandledRejection', (e) => { + process.stderr.write(`REJECT: ${e instanceof Error ? e.stack : String(e)}\n`) + app.exit(1) +}) + +void main() diff --git a/test/relay.ts b/test/relay.ts new file mode 100644 index 0000000..0d116ac --- /dev/null +++ b/test/relay.ts @@ -0,0 +1,397 @@ +/** + * Session Relay security checks. + * + * The relay is a loopback endpoint that hands out and accepts live credentials, + * so its ACCESS CONTROL is the thing worth testing — not the happy path. Each + * case below is an attack the endpoint must refuse: + * + * - a web page POSTing to 127.0.0.1 (wrong Origin) + * - a DNS-rebinding attack (right Origin, wrong Host) + * - a second extension guessing the token + * - brute-forcing the 6-digit pairing code + * - a paired client flooding memory with queued snapshots + * + * Runs against the REAL server in a real Electron main process; the module is + * driven exactly as the app drives it. Nothing here touches the browser + * partition: every test stops at the queue, which is precisely the boundary + * that makes the design safe (receiving != applying). + * + * Run: npm run smoke:relay + */ +import { app } from 'electron' +import { connect } from 'node:net' +import { + DEFAULT_RELAY_PREFS, + RELAY_HEADER, + RELAY_PORT, + isAutoAllowed, + isBlockedHost, + type RelayPrefs +} from '../src/shared/relay' +import * as relay from '../src/main/services/relay' + +let failures = 0 + +function check(name: string, cond: boolean, detail: unknown = ''): void { + const line = cond ? ` ok ${name}` : ` FAIL ${name} ${detail === '' ? '' : String(detail)}` + if (!cond) failures++ + // stderr: Electron on Windows does not reliably deliver stdout to a + // redirected parent shell, so a failure would otherwise vanish in CI. + process.stderr.write(line + '\n') +} + +const BASE = `http://127.0.0.1:${RELAY_PORT}` + +/** A raw request with full control over the headers an attacker would forge. */ +async function call( + path: string, + opts: { origin?: string; host?: string; token?: string; header?: boolean; body?: unknown } = {} +): Promise<{ status: number; json: Record | null }> { + const headers: Record = { 'content-type': 'application/json' } + if (opts.origin !== undefined) headers.origin = opts.origin + if (opts.host !== undefined) headers.host = opts.host + if (opts.token) headers.authorization = `Bearer ${opts.token}` + if (opts.header !== false) headers[RELAY_HEADER] = '1' + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(opts.body ?? {}) + }) + let json: Record | null = null + try { + json = (await res.json()) as Record + } catch { + /* some responses have no body */ + } + return { status: res.status, json } +} + +/** + * A raw-socket request, so we can forge headers `fetch` refuses to send. + * + * This matters for the DNS-rebinding case specifically: undici silently + * replaces a user-supplied `Host` with the real authority, which would make + * that test pass without proving anything. A real attacker writes bytes to a + * socket, so the test does too. + */ +function raw( + path: string, + headers: Record, + body: unknown +): Promise<{ status: number; text: string }> { + return new Promise((resolve, reject) => { + const payload = Buffer.from(JSON.stringify(body ?? {}), 'utf8') + const lines = [ + `POST ${path} HTTP/1.1`, + ...Object.entries(headers).map(([k, v]) => `${k}: ${v}`), + `content-length: ${payload.length}`, + 'connection: close', + '', + '' + ].join('\r\n') + const sock = connect(RELAY_PORT, '127.0.0.1', () => { + sock.write(lines) + sock.write(payload) + }) + const chunks: Buffer[] = [] + sock.on('data', (d) => chunks.push(d)) + sock.on('error', reject) + sock.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + const status = Number(text.match(/^HTTP\/1\.1 (\d{3})/)?.[1] ?? 0) + resolve({ status, text }) + }) + }) +} + +/** A syntactically valid snapshot, so rejections are about AUTH, not shape. */ +function snapshot(origin: string = 'https://example.com'): unknown { + return { + v: 1, + origin, + capturedAt: Date.now(), + cookies: [ + { + name: 'sid', + value: 'secret', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: true, + hostOnly: false, + session: true, + sameSite: 'lax' + } + ] + } +} + +const EXT = 'bekpajpbgjeloofgicpnkgahfllakeao' +const EXT_ORIGIN = `chrome-extension://${EXT}` +const HOST = `127.0.0.1:${RELAY_PORT}` + +async function main(): Promise { + await app.whenReady() + process.stderr.write('session relay:\n') + + await relay.start() + check('listener is up on loopback', relay.status().listening) + check(' and starts unpaired', !relay.status().paired) + + // --- pairing is required before anything works --------------------------- + let r = await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, body: snapshot() }) + check('snapshot without pairing is rejected', r.status === 401, r.status) + + r = await call('/pair', { origin: EXT_ORIGIN, host: HOST, body: { code: '123456' } }) + check('pairing without a code on screen is rejected', r.status === 409, r.status) + + // --- a web page cannot pair ---------------------------------------------- + const { code } = relay.beginPairing() + check('beginPairing issues a 6-digit code', /^\d{6}$/.test(code), code) + + r = await call('/pair', { + origin: 'https://evil.com', + host: HOST, + body: { code, extensionId: EXT } + }) + check('a website cannot pair even with the right code', r.status === 403, r.status) + + // --- an extension cannot claim another extension's id -------------------- + r = await call('/pair', { + origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + host: HOST, + body: { code, extensionId: EXT } + }) + check('extension id must match the request origin', r.status === 400, r.status) + + // --- wrong code ---------------------------------------------------------- + r = await call('/pair', { + origin: EXT_ORIGIN, + host: HOST, + body: { code: '000000', extensionId: EXT } + }) + check('a wrong code is rejected', r.status === 401, r.status) + + // --- the real pairing ---------------------------------------------------- + r = await call('/pair', { origin: EXT_ORIGIN, host: HOST, body: { code, extensionId: EXT } }) + check('the paired extension gets a token', r.status === 200 && typeof r.json?.token === 'string') + const token = String(r.json?.token ?? '') + check(' token is long enough to not be guessable', token.length >= 32, token.length) + check(' status reports paired', relay.status().paired) + check(' status never leaks the token', !JSON.stringify(relay.status()).includes(token)) + + // --- authenticated requests now work ------------------------------------- + r = await call('/hello', { origin: EXT_ORIGIN, host: HOST, token }) + check('paired heartbeat succeeds', r.status === 200, r.status) + + // --- and every forged variant still fails -------------------------------- + r = await call('/hello', { origin: 'https://evil.com', host: HOST, token }) + check('a website with a STOLEN token is still rejected (Origin)', r.status === 401, r.status) + + // A forged Host is the DNS-rebinding case: a page on evil.com resolved to + // 127.0.0.1 reaches our socket, but the browser sends `Host: evil.com`. + // Must go over a raw socket — `fetch` would rewrite the header (see `raw`). + const rebind = await raw( + '/hello', + { + host: 'evil.com', + origin: EXT_ORIGIN, + authorization: `Bearer ${token}`, + [RELAY_HEADER]: '1', + 'content-type': 'application/json' + }, + {} + ) + check('DNS-rebinding is rejected (forged Host)', rebind.status === 401, rebind.status) + + // Control: the same raw request with the correct Host must succeed, or the + // check above would pass for the wrong reason (e.g. a malformed request). + const rawOk = await raw( + '/hello', + { + host: HOST, + origin: EXT_ORIGIN, + authorization: `Bearer ${token}`, + [RELAY_HEADER]: '1', + 'content-type': 'application/json' + }, + {} + ) + check( + ' (control: the same request with the real Host succeeds)', + rawOk.status === 200, + rawOk.status + ) + + r = await call('/hello', { + origin: EXT_ORIGIN, + host: HOST, + token: 'wrong-token-wrong-token-wrong' + }) + check('a bad token is rejected', r.status === 401, r.status) + + r = await call('/hello', { origin: EXT_ORIGIN, host: HOST }) + check('a missing token is rejected', r.status === 401, r.status) + + r = await call('/hello', { origin: EXT_ORIGIN, host: HOST, token, header: false }) + check('a request without the custom header is rejected', r.status === 401, r.status) + + // --- CORS preflight only ever names the paired extension ----------------- + const pre = await fetch(`${BASE}/snapshot`, { + method: 'OPTIONS', + headers: { origin: 'https://evil.com', host: HOST } + }) + check('preflight refuses a website origin', pre.status === 403, pre.status) + + // --- the blocklist and trust rules --------------------------------------- + // These decide whether credentials move without asking, so the matching + // edge cases are asserted directly rather than inferred from behaviour. + const B = (host: string, patterns: string[]): boolean => isBlockedHost(host, patterns) + + check('blocks an exact host', B('example.com', ['example.com'])) + check('blocks a subdomain', B('app.example.com', ['example.com'])) + check('blocks a deep subdomain', B('a.b.example.com', ['example.com'])) + check('does NOT block a lookalike suffix', !B('notexample.com', ['example.com'])) + check( + 'does NOT block a domain that merely contains it', + !B('example.com.evil.net', ['example.com']) + ) + check('does NOT block an unrelated host', !B('other.com', ['example.com'])) + check('ignores case', B('APP.Example.COM', ['example.com'])) + check('tolerates a scheme in the pattern', B('app.example.com', ['https://example.com'])) + check('tolerates a *. prefix', B('app.example.com', ['*.example.com'])) + check('tolerates a path and port', B('app.example.com', ['example.com:8443/foo'])) + check('tolerates a trailing dot', B('example.com.', ['example.com'])) + check('an empty host is refused, not allowed', B('', ['example.com'])) + check('an empty pattern matches nothing', !B('example.com', [''])) + + const prefsFor = (trusted: string[], blocked: string[]): RelayPrefs => ({ + autoSend: true, + trusted, + blocked + }) + + check( + 'a trusted origin is auto-allowed', + isAutoAllowed('https://app.example.com', prefsFor(['https://app.example.com'], [])) + ) + check( + 'trust is EXACT: a sibling subdomain is not covered', + !isAutoAllowed('https://admin.example.com', prefsFor(['https://app.example.com'], [])) + ) + check( + 'trust is EXACT: a different scheme is not covered', + !isAutoAllowed('http://app.example.com', prefsFor(['https://app.example.com'], [])) + ) + check( + 'a block BEATS trust', + !isAutoAllowed( + 'https://app.example.com', + prefsFor(['https://app.example.com'], ['example.com']) + ) + ) + check( + 'the master switch disables everything', + !isAutoAllowed('https://app.example.com', { + autoSend: false, + trusted: ['https://app.example.com'], + blocked: [] + }) + ) + check( + 'an untrusted origin is not auto-allowed', + !isAutoAllowed('https://x.com', prefsFor([], [])) + ) + + // Blocking must also REVOKE trust, so the UI can never show a trusted entry + // that silently does nothing. + relay.setPrefs({ autoSend: true, trusted: ['https://app.example.com'], blocked: [] }) + check('trust is stored', relay.status().prefs.trusted.length === 1) + relay.setPrefs({ blocked: ['example.com'] }) + check( + 'adding a block revokes trust it now covers', + relay.status().prefs.trusted.length === 0, + JSON.stringify(relay.status().prefs) + ) + let threw = false + try { + relay.trustOrigin('https://app.example.com') + } catch { + threw = true + } + check('trusting a blocked origin is refused', threw) + + // A blocked origin must be refused at the WIRE, not merely un-automated — + // a stale extension must not be able to relay it. + const blockedSend = await call('/snapshot', { + origin: EXT_ORIGIN, + host: HOST, + token, + body: snapshot('https://app.example.com') + }) + check('a blocked origin is rejected on arrival', blockedSend.status === 403, blockedSend.status) + check(' and nothing was queued', relay.status().pending.length === 0) + + relay.setPrefs({ ...DEFAULT_RELAY_PREFS }) + + // --- snapshots queue, they do NOT apply ---------------------------------- + r = await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, token, body: snapshot() }) + check('a valid snapshot is queued', r.status === 202, r.status) + check(' it appears as pending', relay.status().pending.length === 1) + const pending = relay.status().pending[0] + check(' pending reports counts, not values', pending.cookieCount === 1) + check(' no cookie VALUE crosses into status', !JSON.stringify(relay.status()).includes('secret')) + + // --- malformed input is rejected without throwing ------------------------ + r = await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, token, body: { v: 99 } }) + check('an unknown protocol version is rejected', r.status === 400, r.status) + + r = await call('/snapshot', { + origin: EXT_ORIGIN, + host: HOST, + token, + body: { v: 1, origin: 'file:///etc/passwd', cookies: [] } + }) + check('a non-http origin is rejected', r.status === 400, r.status) + + // --- a paired client cannot flood memory --------------------------------- + for (let i = 0; i < 6; i++) { + await call('/snapshot', { origin: EXT_ORIGIN, host: HOST, token, body: snapshot() }) + } + check( + 'the pending queue is capped', + relay.status().pending.length <= 5, + relay.status().pending.length + ) + + // --- rejecting drops it untouched ---------------------------------------- + const before = relay.status().pending.length + relay.rejectPending(relay.status().pending[0].id) + check('reject removes the snapshot', relay.status().pending.length === before - 1) + + // --- unpair revokes immediately ------------------------------------------ + relay.unpair() + check('unpair clears the pairing', !relay.status().paired) + check(' and drops queued snapshots', relay.status().pending.length === 0) + r = await call('/hello', { origin: EXT_ORIGIN, host: HOST, token }) + check('the old token stops working at once', r.status === 401, r.status) + + relay.stop() + check('stop closes the listener', !relay.status().listening) + + process.stderr.write( + failures ? `\nRELAY FAILED — ${failures} failing\n` : '\nAll relay checks passed.\n' + ) + app.exit(failures ? 1 : 0) +} + +process.on('uncaughtException', (e) => { + process.stderr.write(`CRASH: ${e?.stack ?? e}\n`) + app.exit(1) +}) +process.on('unhandledRejection', (e) => { + process.stderr.write(`REJECT: ${e instanceof Error ? e.stack : String(e)}\n`) + app.exit(1) +}) + +void main()