From 5d4b0c525aa0cc774ac3012f6c144536a1192dd3 Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:02:43 -0400 Subject: [PATCH 1/4] feat(relay): Session Relay backend - authenticated loopback + MV3 extension Debugging a signed-in site in Roxy's browser means signing in again. The Session Relay lets a real browser hand its session over: a bundled MV3 extension captures a site's cookies and storage, and Roxy applies them to its own partition. This is the backend half - protocol, listener, storage import, extension, and its security tests. The Settings wizard lands next. A snapshot is live credentials, so the design is built around two rules: 1. RECEIVING IS NOT APPLYING. The listener only ever parks a snapshot in memory and tells the UI. The single path that writes to the browser partition is applyPending(), reachable only from an explicit renderer call after the user approves that specific transfer. Compromise of the endpoint alone cannot inject a session. 2. LOOPBACK IS NOT TRUSTED. Any local process - and, via DNS rebinding, any WEBSITE - can reach 127.0.0.1. So every request must prove: a bearer token from an explicit pairing handshake; an `Origin` of exactly the paired chrome-extension:// id (browsers set this and pages cannot forge it); and a `Host` of 127.0.0.1:, which is what defeats rebinding. A required custom header also forces a preflight, so no page can reach this with a "simple" cross-origin POST. The extension asks for site access at send time via optional_host_permissions and a user gesture, so installing it does not grant access to every page you visit. Its manifest pins a fixed key, because an unpacked extension otherwise gets a path-derived id and every reinstall would silently break pairing. Two constraints worth knowing: - localStorage has no Electron write API (`clearStorageData` only deletes), so it is written by loading the origin in a hidden page on the browser partition and assigning through the real `window.localStorage`. Values are injected as one JSON literal - interpolating a token containing a quote would otherwise execute in the page's own origin. - Electron 33's cookie API has no `partitionKey`, so CHIPS cookies cannot be faithfully recreated. They are counted and reported as skipped rather than written into the unpartitioned jar, which would put them in the wrong place. test/relay.ts drives the real server in a real Electron process and asserts the refusals, not the happy path: a website POSTing with a stolen token, DNS rebinding, a wrong/absent token, a missing preflight header, an extension claiming another's id, pairing-code brute force, and queue flooding. It also asserts no cookie value ever reaches the status object. That suite caught a real bug: the rebinding test passed vacuously because undici silently rewrites a user-set `Host`. It now forges the request over a raw socket, with a control case proving the same request succeeds when the Host is correct. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- package.json | 5 +- resources/session-relay/background.js | 132 ++++++ resources/session-relay/manifest.json | 28 ++ resources/session-relay/popup.html | 124 ++++++ resources/session-relay/popup.js | 274 +++++++++++++ src/main/db/repo.ts | 24 ++ src/main/index.ts | 5 + src/main/ipc/index.ts | 18 + src/main/services/browser.ts | 26 ++ src/main/services/relay-install.ts | 73 ++++ src/main/services/relay.ts | 566 ++++++++++++++++++++++++++ src/main/services/storage.ts | 187 +++++++++ src/preload/index.ts | 17 + src/shared/api.ts | 23 ++ src/shared/ipc.ts | 15 + src/shared/relay.ts | 186 +++++++++ test/relay.ts | 299 ++++++++++++++ 17 files changed, 2000 insertions(+), 2 deletions(-) create mode 100644 resources/session-relay/background.js create mode 100644 resources/session-relay/manifest.json create mode 100644 resources/session-relay/popup.html create mode 100644 resources/session-relay/popup.js create mode 100644 src/main/services/relay-install.ts create mode 100644 src/main/services/relay.ts create mode 100644 src/main/services/storage.ts create mode 100644 src/shared/relay.ts create mode 100644 test/relay.ts diff --git a/package.json b/package.json index 4de962b..9c8c160 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,12 @@ "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" }, "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..a66ecec --- /dev/null +++ b/resources/session-relay/background.js @@ -0,0 +1,132 @@ +/** + * 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 + +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() + 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') +} + +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) + +/** 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 +}) 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..d12058b --- /dev/null +++ b/resources/session-relay/popup.js @@ -0,0 +1,274 @@ +/** + * 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.') + + const snapshot = await capture(tab, origin, want) + 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. + preview(tab, origin).then(({ cookies, local, session }) => { + app.querySelector('#n-cookies').textContent = cookies == null ? '' : String(cookies) + app.querySelector('#n-local').textContent = local == null ? '—' : String(local) + app.querySelector('#n-session').textContent = session == null ? '—' : String(session) + }) +} + +/** + * Counts for the checkboxes. Best-effort: without site permission yet we can + * still read cookies (that permission is separate), but storage needs the page, + * so those stay "—" until the user sends. + */ +async function preview(tab, origin) { + const out = { cookies: null, local: null, session: null } + try { + out.cookies = (await chrome.cookies.getAll({ url: origin + '/' })).length + } catch { + /* no cookie access yet */ + } + 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 { + /* needs permission; counts fill in after the user grants it */ + } + 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..c6d02c1 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -133,6 +133,30 @@ 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) +} + // ---- 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..14e55f4 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 } from '../../shared/relay' import { pruneWorktrees, removeWorktreeForChat, renameWorkstreamBranch } from '../services/worktree' import { checkForUpdates, quitAndInstall, getUpdateState } from '../services/updater' import { @@ -781,6 +784,21 @@ 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) => + relay.applyPending(id, choice) + ) + ipcMain.handle(CHANNELS.relayReject, (_e, id: string) => relay.rejectPending(id)) + ipcMain.handle(CHANNELS.relayInstallExtension, () => relayInstall.install()) + ipcMain.handle(CHANNELS.relayRevealExtension, () => relayInstall.reveal()) + // ---- 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..d95e844 --- /dev/null +++ b/src/main/services/relay.ts @@ -0,0 +1,566 @@ +/** + * 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 { + MAX_SNAPSHOT_BYTES, + PAIRING_TTL_MS, + RELAY_HEADER, + RELAY_PORT, + RELAY_PROTOCOL_VERSION, + isImportableOrigin, + type PendingSnapshot, + type RelayCookie, + type RelayImportChoice, + type RelayImportResult, + type RelaySnapshot, + type RelayStatus +} 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 }[] +} + +const state: State = { + server: null, + port: RELAY_PORT, + pairing: null, + code: null, + pending: [] +} + +/** + * 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) + } +} + +/** 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() + return void json(res, 200, { ok: true, v: RELAY_PROTOCOL_VERSION }) + } + + 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' }) + + 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 }) + state.lastSeenAt = Date.now() + 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 parked snapshot. This is the ONLY path that writes a relayed session + * into the browser partition, and it runs only when the renderer says the user + * approved it — the listener above can never reach here on its own. + * + * 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. + */ +export async function applyPending( + id: string, + choice: RelayImportChoice +): Promise { + const snapshot = takePending(id) + if (!snapshot) throw new Error('That transfer is no longer waiting for approval.') + + 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)) + } + } + } + + markTransferred() + 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)) +} + +function load(): void { + const raw = repo.getRelayPairing() + if (!raw) return + try { + state.pairing = JSON.parse(decryptSecret(JSON.parse(raw))) + } 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..d91cd9f 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,22 @@ 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) => ipcRenderer.invoke(CHANNELS.relayApply, id, choice), + 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/shared/api.ts b/src/shared/api.ts index 2bca98d..f0e3d8e 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, RelayStatus } from './relay' import type { AddMessageInput, AppSettings, @@ -898,6 +899,28 @@ 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. */ + apply(id: string, choice: RelayImportChoice): 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..cdc656c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -212,6 +212,21 @@ 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', + /** 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..d9cfd04 --- /dev/null +++ b/src/shared/relay.ts @@ -0,0 +1,186 @@ +/** + * 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) and + * deliberately manual: nothing is captured without a click in the extension, + * and nothing is applied without a second confirmation inside Roxy. + * + * 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[] +} + +/** 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 + } +} diff --git a/test/relay.ts b/test/relay.ts new file mode 100644 index 0000000..d9b02e4 --- /dev/null +++ b/test/relay.ts @@ -0,0 +1,299 @@ +/** + * 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 { RELAY_HEADER, RELAY_PORT } 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 = '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) + + // --- 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() From c8ef552040365812d8701ddc1bd78d4ad9fe7d9f Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:30:31 -0400 Subject: [PATCH 2/4] feat(relay): Session Relay UI - guided install, pairing, approval The Settings half of the Session Relay: the "Connect browser" wizard, the paired-state manage screen, and the prompt that approves an incoming transfer. The install is five manual steps because loading an unpacked extension is - we cannot click through another browser's UI. So the wizard automates the parts we can (copying the extension to Documents, revealing the folder, minting a short-lived pairing code) and makes the rest unambiguous. The approval prompt shows COUNTS, never values. Main keeps the credentials until the user says yes, so there is nothing in the renderer to leak; a test asserts no cookie value reaches the status object. Two Chrome behaviours worth recording, since both cost real debugging time: - Chrome 137+ REMOVED `--load-extension` from branded builds (malware abuse), and the DisableLoadExtensionCommandLineSwitch escape hatch has since been removed too. This does NOT affect the product - "Load unpacked" with Developer mode on, which is what the wizard walks through, still works. It only affects automation, so the e2e harness uses CDP's `Extensions.loadUnpacked`, the documented replacement. - The wizard copies browser URLs to the clipboard rather than opening them. Browsers refuse to let another app navigate them to `chrome://` pages, so an "Open" button would silently do nothing. test/relay-e2e.ts drives a REAL Chrome: it loads the actual bundled extension, checks it takes the extension ID we authorize, pairs over the real protocol, sends a snapshot, and asserts it is QUEUED rather than applied - then that Disconnect cuts the extension off immediately. Together with test/relay.ts (which asserts the server's refusals) both sides of the wire are covered by real code. It is not in `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`. Getting there needed two fixes worth knowing: the extension's handlers are exposed on the worker's own global for the harness, because a service worker cannot `chrome.runtime.sendMessage` to itself (no receiver); and the CDP client uses the `ws` package because Electron's main process has no global WebSocket, and routing through a hidden `about:blank` renderer fails - Chrome rejects CDP socket upgrades from an opaque origin. Version bumped to 0.0.85. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- package-lock.json | 4 +- package.json | 5 +- resources/session-relay/background.js | 6 + src/renderer/src/components/SessionRelay.tsx | 573 +++++++++++++++++++ src/renderer/src/routes/Settings.tsx | 5 + test/relay-e2e.ts | 242 ++++++++ 6 files changed, 831 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/SessionRelay.tsx create mode 100644 test/relay-e2e.ts 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 9c8c160..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)", @@ -41,7 +41,8 @@ "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:relay": "esbuild test/relay.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/relay.cjs && electron test/.out/relay.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 index a66ecec..fe381b0 100644 --- a/resources/session-relay/background.js +++ b/resources/session-relay/background.js @@ -130,3 +130,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, respond) => { // 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 } diff --git a/src/renderer/src/components/SessionRelay.tsx b/src/renderer/src/components/SessionRelay.tsx new file mode 100644 index 0000000..f143078 --- /dev/null +++ b/src/renderer/src/components/SessionRelay.tsx @@ -0,0 +1,573 @@ +import { useCallback, useEffect, useState } from 'react' +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) + + const apply = async (): Promise => { + setBusy(true) + setError(null) + try { + setResult(await api.relay.apply(snapshot.id, choice)) + } 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]) + + const relayNotListening = status && !status.listening + + return ( +
+
+
+ +
+ {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}

} +
+ )} +
+
+ ) +} + +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 + +
+
+ ) +} + +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/test/relay-e2e.ts b/test/relay-e2e.ts new file mode 100644 index 0000000..bf7986c --- /dev/null +++ b/test/relay-e2e.ts @@ -0,0 +1,242 @@ +/** + * 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 { 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)) + + 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() From 87874457351634cb7c9aa7b5bb91f7106d2e621b Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:46:40 -0400 Subject: [PATCH 3/4] fix(relay): dialog was clipped, and cookie counts read 0 when unknown Two UX bugs, both visible in a single screenshot of the popup on Gmail. 1. THE SETUP DIALOG WAS CLIPPED. It is rendered from inside the Session Relay card, which is `overflow-hidden` to clip its own rounded corners - and an `overflow` ancestor clips a `position: fixed` descendant. The dialog was cut off mid-step. It now portals to , matching ImagePreview, which also makes it immune to any future ancestor with `overflow`, `transform` or `filter`. While there it picked up the house dialog treatment the other two already have: scrim/panel animations, click-outside and Escape to close, and role="dialog". Closing by any route now also cancels a live pairing code. It is short-lived and single-purpose, so leaving one valid after the user walks away is needless exposure. 2. "COOKIES 0" WAS A LIE. `chrome.cookies.getAll` returns an empty array rather than an error when the extension lacks host access for the origin ("This method only retrieves cookies for domains that the extension has host permissions to"). Since we request site access at SEND time, the popup always rendered 0 before the first send - claiming a signed-in site had no cookies, which is exactly backwards. The count now distinguishes unknown from zero: permissions.contains() is checked first, unknown renders as a dash with "Counts appear once you allow access to this site", and the real number fills in the moment permission is granted. Storage counts legitimately appear earlier, because activeTab grants a temporary host permission that chrome.scripting honours and the cookies API does not - which is why the screenshot showed 36 localStorage keys next to 0 cookies. Sending is now also refused when cookies were requested but none were readable, rather than relaying an empty session that looks like success. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- resources/session-relay/popup.js | 63 ++++++++++++++++---- src/renderer/src/components/SessionRelay.tsx | 42 +++++++++++-- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/resources/session-relay/popup.js b/resources/session-relay/popup.js index d12058b..eff0af1 100644 --- a/resources/session-relay/popup.js +++ b/resources/session-relay/popup.js @@ -110,6 +110,7 @@ async function renderSend() { +

@@ -139,7 +140,16 @@ async function renderSend() { 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.') @@ -156,25 +166,52 @@ async function renderSend() { }) // Show counts up front so the user knows what they're about to hand over. - preview(tab, origin).then(({ cookies, local, session }) => { - app.querySelector('#n-cookies').textContent = cookies == null ? '' : String(cookies) - app.querySelector('#n-local').textContent = local == null ? '—' : String(local) - app.querySelector('#n-session').textContent = session == null ? '—' : String(session) - }) + 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. Best-effort: without site permission yet we can - * still read cookies (that permission is separate), but storage needs the page, - * so those stay "—" until the user sends. + * 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 } - try { - out.cookies = (await chrome.cookies.getAll({ url: origin + '/' })).length - } catch { - /* no cookie access yet */ + + 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 }, @@ -185,7 +222,7 @@ async function preview(tab, origin) { out.session = res.result[1] } } catch { - /* needs permission; counts fill in after the user grants it */ + // Page blocks injection (a chrome:// page, a PDF viewer): stays unknown. } return out } diff --git a/src/renderer/src/components/SessionRelay.tsx b/src/renderer/src/components/SessionRelay.tsx index f143078..835e3ed 100644 --- a/src/renderer/src/components/SessionRelay.tsx +++ b/src/renderer/src/components/SessionRelay.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' import { Check, Globe, @@ -303,11 +304,41 @@ function SetupDialog({ 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 - return ( -
-
+ // 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()} + >
@@ -315,7 +346,7 @@ function SetupDialog({
)}
-
+
, + document.body ) } From a7d6e3311a075b55dc84ebbd57b7a2bdd68bd682 Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:57:18 -0400 Subject: [PATCH 4/4] feat(relay): trusted origins auto-transfer, with a blocklist that overrides "Approve once, then never click again", plus a hard veto for sites that should never leave the browser. WHY NOT A PLAIN BLOCKLIST. Auto-sending from any site the user has not already approved would require at install time - a permanent "read all your data on all websites" prompt - just to enable something that harvests everything by default. Chrome's permission model is built to prevent exactly that, and working around it would be the wrong instinct. Trust is therefore opt-in per origin, which is also what the extension can actually DO: auto-send only ever reaches an origin the user separately granted host access to, so it can never trigger a permission prompt on its own. The two lists are deliberately asymmetric, because they fail in opposite directions: - TRUST is exact-origin. https://app.example.com never implies https://admin.example.com, nor the http:// version. - BLOCKS match the apex and every subdomain, are checked BEFORE trust, and adding one revokes any trust it now covers - so a blocked entry can never sit next to a trusted one that silently does nothing. Blocks are enforced in BOTH the extension and Roxy. The extension caches prefs from each heartbeat so it can decline without a round trip, but the server re-checks on arrival: a stale or older extension must not be able to relay a blocked site. Auto-applied snapshots skip the queue entirely, so `/snapshot` is now the one place that can write without a prompt - it refuses blocked origins before anything else. Auto-send is driven by `cookies.onChanged` (a token rotating mid-session is the case this exists for) and by completing a navigation, both behind a 15s per-origin cooldown - every analytics ping rewrites a cookie, and without the cooldown a busy site would relay itself hundreds of times a minute. Settings gains the master switch, both lists, and a recent-transfers list so automatic activity is auditable rather than invisible. Tests cover the matching rules directly, since they gate credential movement: `example.com` must block `app.example.com` but NOT `notexample.com` (no dot boundary) nor `example.com.evil.net` (not a suffix); trust must not leak across subdomains or schemes; a block must beat trust; the master switch must disable everything. The e2e run proves the whole path in real Chrome - a trusted origin applies with nothing queued, a repeat is throttled, a block refuses a trusted origin, and the extension picks the block up from its next heartbeat. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- resources/session-relay/background.js | 181 +++++++++++++++++- src/main/db/repo.ts | 18 ++ src/main/ipc/index.ts | 11 +- src/main/services/relay.ts | 185 +++++++++++++++++-- src/preload/index.ts | 5 +- src/renderer/src/components/SessionRelay.tsx | 168 ++++++++++++++++- src/shared/api.ts | 14 +- src/shared/ipc.ts | 3 + src/shared/relay.ts | 136 +++++++++++++- test/relay-e2e.ts | 54 +++++- test/relay.ts | 102 +++++++++- 11 files changed, 845 insertions(+), 32 deletions(-) diff --git a/resources/session-relay/background.js b/resources/session-relay/background.js index fe381b0..37bdfbf 100644 --- a/resources/session-relay/background.js +++ b/resources/session-relay/background.js @@ -17,6 +17,19 @@ 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 @@ -57,6 +70,9 @@ async function pair(code) { 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}).` } @@ -89,8 +105,166 @@ async function heartbeat() { // 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 }) } @@ -102,6 +276,11 @@ chrome.alarms?.onAlarm.addListener((alarm) => { 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() @@ -135,4 +314,4 @@ chrome.runtime.onMessage.addListener((msg, _sender, respond) => { // 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 } +globalThis.__roxyRelay = { pair, sendSnapshot, getToken, heartbeat, prefs: () => prefs } diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index c6d02c1..94dbb8a 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -157,6 +157,24 @@ 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/ipc/index.ts b/src/main/ipc/index.ts index 14e55f4..5373fca 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -51,7 +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 } from '../../shared/relay' +import type { RelayImportChoice, RelayPrefs } from '../../shared/relay' import { pruneWorktrees, removeWorktreeForChat, renameWorkstreamBranch } from '../services/worktree' import { checkForUpdates, quitAndInstall, getUpdateState } from '../services/updater' import { @@ -792,12 +792,17 @@ export function registerIpc(): void { 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) => - relay.applyPending(id, choice) + 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 diff --git a/src/main/services/relay.ts b/src/main/services/relay.ts index d95e844..e5709d8 100644 --- a/src/main/services/relay.ts +++ b/src/main/services/relay.ts @@ -22,18 +22,24 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } 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 RelayStatus, + type RelayTransfer } from '../../shared/relay' import { CHANNELS } from '../../shared/ipc' import { decryptSecret, encryptSecret } from './secure' @@ -60,6 +66,12 @@ interface State { 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 = { @@ -67,9 +79,15 @@ const state: State = { port: RELAY_PORT, pairing: null, code: null, - pending: [] + 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. @@ -185,10 +203,50 @@ export function status(): RelayStatus { lastSeenAt: state.lastSeenAt, lastTransferAt: state.lastTransferAt, pairingExpiresAt: state.code?.expiresAt, - pending: state.pending.map((p) => p.meta) + 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 @@ -349,7 +407,11 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (url.pathname === '/hello') { state.lastSeenAt = Date.now() broadcast() - return void json(res, 200, { ok: true, v: RELAY_PROTOCOL_VERSION }) + // 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') { @@ -366,6 +428,35 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise 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(), @@ -380,7 +471,6 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise approxBytes: sizeOf(snapshot) } state.pending.push({ meta, snapshot }) - state.lastSeenAt = Date.now() 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. @@ -457,22 +547,20 @@ export function markTransferred(): void { } /** - * Apply a parked snapshot. This is the ONLY path that writes a relayed session - * into the browser partition, and it runs only when the renderer says the user - * approved it — the listener above can never reach here on its own. + * 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. */ -export async function applyPending( - id: string, +async function applySnapshot( + snapshot: RelaySnapshot, choice: RelayImportChoice ): Promise { - const snapshot = takePending(id) - if (!snapshot) throw new Error('That transfer is no longer waiting for approval.') - const result: RelayImportResult = { cookiesImported: 0, cookiesFailed: 0, @@ -531,7 +619,42 @@ export async function applyPending( } } - markTransferred() + 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 } @@ -553,11 +676,39 @@ function persist(): void { 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 raw = repo.getRelayPairing() - if (!raw) return + 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(raw))) + 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. diff --git a/src/preload/index.ts b/src/preload/index.ts index d91cd9f..416d2d8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -238,7 +238,10 @@ const roxy: RoxyApi = { beginPairing: () => ipcRenderer.invoke(CHANNELS.relayBeginPairing), cancelPairing: () => ipcRenderer.invoke(CHANNELS.relayCancelPairing), unpair: () => ipcRenderer.invoke(CHANNELS.relayUnpair), - apply: (id, choice) => ipcRenderer.invoke(CHANNELS.relayApply, id, choice), + 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), diff --git a/src/renderer/src/components/SessionRelay.tsx b/src/renderer/src/components/SessionRelay.tsx index 835e3ed..399a3c9 100644 --- a/src/renderer/src/components/SessionRelay.tsx +++ b/src/renderer/src/components/SessionRelay.tsx @@ -97,12 +97,16 @@ function PendingCard({ snapshot }: { snapshot: PendingSnapshot }): JSX.Element { 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)) + setResult(await api.relay.apply(snapshot.id, choice, trust)) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -178,6 +182,21 @@ function PendingCard({ snapshot }: { snapshot: PendingSnapshot }): JSX.Element { Values are hidden and stay in Roxy's main process until you import.

+ + {error &&

{error}

}
@@ -521,6 +540,153 @@ function ManageBody({ 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()} + +
  • + ))} +
+
+ )}
) } diff --git a/src/shared/api.ts b/src/shared/api.ts index f0e3d8e..fdc3f68 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -2,7 +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, RelayStatus } from './relay' +import type { RelayImportChoice, RelayImportResult, RelayPrefs, RelayStatus } from './relay' import type { AddMessageInput, AppSettings, @@ -911,8 +911,16 @@ export interface RoxyApi { cancelPairing(): Promise /** Revoke the paired extension's token. */ unpair(): Promise - /** Apply a queued snapshot. This is the only path that writes it. */ - apply(id: string, choice: RelayImportChoice): 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. */ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index cdc656c..218f783 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -224,6 +224,9 @@ export const CHANNELS = { 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', diff --git a/src/shared/relay.ts b/src/shared/relay.ts index d9cfd04..5684bb4 100644 --- a/src/shared/relay.ts +++ b/src/shared/relay.ts @@ -4,9 +4,25 @@ * * 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) and - * deliberately manual: nothing is captured without a click in the extension, - * and nothing is applied without a second confirmation inside Roxy. + * 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 @@ -150,6 +166,10 @@ export interface RelayStatus { 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. */ @@ -184,3 +204,113 @@ export function isImportableOrigin(origin: string): boolean { 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 index bf7986c..4210f6c 100644 --- a/test/relay-e2e.ts +++ b/test/relay-e2e.ts @@ -27,7 +27,7 @@ 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 { RELAY_EXTENSION_ID } from '../src/shared/relay' +import { DEFAULT_RELAY_PREFS, RELAY_EXTENSION_ID } from '../src/shared/relay' const CDP_PORT = 9333 const EXT = resolve('resources/session-relay') @@ -213,6 +213,58 @@ async function main(): Promise { )) 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() diff --git a/test/relay.ts b/test/relay.ts index d9b02e4..0d116ac 100644 --- a/test/relay.ts +++ b/test/relay.ts @@ -20,7 +20,14 @@ */ import { app } from 'electron' import { connect } from 'node:net' -import { RELAY_HEADER, RELAY_PORT } from '../src/shared/relay' +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 @@ -98,7 +105,7 @@ function raw( } /** A syntactically valid snapshot, so rejections are about AUTH, not shape. */ -function snapshot(origin = 'https://example.com'): unknown { +function snapshot(origin: string = 'https://example.com'): unknown { return { v: 1, origin, @@ -236,6 +243,97 @@ async function main(): Promise { }) 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)