diff --git a/README.md b/README.md index 3f743c5b9..a0d0bb042 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ or, on Windows: $s="$env:TEMP\dsh-install.ps1"; irm https://raw.githubusercontent.com/Lum1104/dsh-browser/refs/heads/main/scripts/install.ps1 -OutFile $s; powershell -NoProfile -ExecutionPolicy Bypass -File $s ``` -The installer downloads `main`, builds and registers the bridge plugin, builds the Chrome extension into `~/.dsh/browser-extension`, and opens `chrome://extensions`. On the first install, load that directory as an unpacked extension; on updates, click **Reload**. Restart dsh if it is already running. +The installer downloads `main`, builds and registers the bridge plugin, builds the Chrome extension into `~/.dsh/browser-extension`, and opens `chrome://extensions`. On the first install, load that directory as an unpacked extension; on updates, click **Reload**. Restart dsh if it is already running. Note for updates from pre-0.1.5 builds: the extension now pins a stable manifest `key`, so its id (and the per-id stored settings) change once — re-enter the bridge address and token in the panel if you had configured a remote bridge. `scripts/install.sh` covers macOS and Linux, and `scripts/install.ps1` covers Windows; both write the same managed workspace and the same install metadata. The installer copies the extension path to the clipboard when a clipboard tool is available (`pbcopy`, `wl-copy`, `xclip`, `xsel`, or PowerShell's `Set-Clipboard`), and prints the path either way. When no Chrome or Chromium install is found, it prints the command that installs one; set `DSH_INSTALL_BROWSER=1` to let the installer attempt that install itself. @@ -183,7 +183,8 @@ If you encounter `cache.hydratePrepared is not a function`, update the repositor ## Security - The bridge path sits outside the `/api` trust boundary and performs its own bearer-token authentication. -- Local Chrome extension origins retain zero-configuration loopback access; Firefox origins are per-install UUIDs and must present the bearer token. +- Zero-config loopback access is pinned to this extension's stable id (derived from the public manifest `key`); unlisted extensions must present the bearer token. The key is public, so the pin stops *incidental* cross-extension access — an extension deliberately copying the key inherits the id. Deployments that must resist hostile extensions should set `trustedExtensionOrigins: []` and pair the bearer token. Firefox origins are per-install UUIDs and always present the token. +- The exemption is loopback-only. A reverse proxy terminating on 127.0.0.1 (`tailscale serve`, `ssh -L`, nginx) makes proxied clients appear as loopback — use the hardened mode above for such deployments. - Privileged gateway methods such as `settings.*`, `credentials.*`, and `host.open*` reject non-loopback sources. - The browser-page pipeline is text-only and never captures screenshots; explicitly attached chat images use dsh's durable attachment service. Password and payment-card values never leave the page. - When work begins, the assistant binds to the active tab (at prompt submission, or at the first direct browser-tool call). If you switch tabs manually, later browser actions pause and the side panel asks whether the assistant should continue on the original tab or follow the new one. Choosing the original tab permits background operation; the extension never silently retargets or changes your visible tab. Closing the controlled tab also pauses tools until you explicitly select the current page. diff --git a/README.zh.md b/README.zh.md index fc8fe4d45..e9ff2bafa 100644 --- a/README.zh.md +++ b/README.zh.md @@ -99,7 +99,7 @@ Windows 请运行: $s="$env:TEMP\dsh-install.ps1"; irm https://raw.githubusercontent.com/Lum1104/dsh-browser/refs/heads/main/scripts/install.ps1 -OutFile $s; powershell -NoProfile -ExecutionPolicy Bypass -File $s ``` -安装器会下载 `main`、构建并注册桥插件、把 Chrome 扩展构建到 `~/.dsh/browser-extension`,然后打开 `chrome://extensions`。首次安装时,请把该目录作为已解压扩展加载;更新时点击**重新加载**。如果 dsh 已在运行,请重启。 +安装器会下载 `main`、构建并注册桥插件、把 Chrome 扩展构建到 `~/.dsh/browser-extension`,然后打开 `chrome://extensions`。首次安装时,请把该目录作为已解压扩展加载;更新时点击**重新加载**。如果 dsh 已在运行,请重启。从 0.1.5 之前的版本更新请注意:扩展固定了 manifest `key`,其 id(及按 id 存储的设置)会变更一次——如曾配置远程桥地址与 token,请在面板中重新填写。 `scripts/install.sh` 覆盖 macOS 与 Linux,`scripts/install.ps1` 覆盖 Windows;两者写入同一个托管工作区和同一份安装元数据。当系统提供剪贴板工具(`pbcopy`、`wl-copy`、`xclip`、`xsel` 或 PowerShell 的 `Set-Clipboard`)时,安装器会把扩展路径复制到剪贴板;无论是否复制成功都会打印该路径。若未检测到 Chrome/Chromium,安装器会打印对应的安装命令;设置 `DSH_INSTALL_BROWSER=1` 可让安装器尝试自动安装。 @@ -183,7 +183,8 @@ pnpm --filter dsh-browser-extension run test ## 安全 - 桥路径在 `/api` 信任栅栏之外,自带 bearer token 认证。 -- Chrome 扩展的本地 Origin 保留零配置回环访问;Firefox Origin 是每次安装生成的 UUID,必须携带 bearer token。 +- 零配置回环访问只授予本扩展的固定 id(由公开的 manifest `key` 派生);未列入的扩展必须携带 bearer token。该 key 是公开的,因此这只防*误连*其它扩展——刻意复制 key 的扩展会得到相同 id。需要抵御恶意扩展的部署应设置 `trustedExtensionOrigins: []` 并强制 token 配对。Firefox Origin 是每次安装生成的 UUID,始终需要携带 token。 +- 该豁免仅限回环。在 127.0.0.1 上终结连接的反向代理(`tailscale serve`、`ssh -L`、nginx)会让代理客户端表现为回环——此类部署请使用上述加固模式。 - 特权网关方法(`settings.*`/`credentials.*`/`host.open*`)对非回环来源一律拒绝。 - 单活动连接;浏览器页面管线为纯文本且不截图;用户主动添加的对话图片交给 dsh 持久附件服务,密码和卡号值永不回传。 - 助手开始工作时会绑定当时的活动标签页(提交提示时绑定;直接调用浏览器工具时则在首次调用绑定)。用户手动切页后,后续浏览器操作会暂停,侧栏会询问让助手继续原页面还是跟随新页面;选择原页面后允许在后台继续,但扩展绝不静默改绑或切换用户正在看的页面。受控标签页关闭后也会暂停,直到用户显式选择当前页。 diff --git a/extensions/dsh-browser/manifest.firefox.json b/extensions/dsh-browser/manifest.firefox.json index 8d6dda156..5aedc1be3 100644 --- a/extensions/dsh-browser/manifest.firefox.json +++ b/extensions/dsh-browser/manifest.firefox.json @@ -3,7 +3,7 @@ "name": "__MSG_extensionName__", "description": "__MSG_extensionDescription__", "default_locale": "en", - "version": "0.1.4", + "version": "0.1.5", "browser_specific_settings": { "gecko": { "id": "dsh-browser@lum1104.github.io", diff --git a/extensions/dsh-browser/manifest.json b/extensions/dsh-browser/manifest.json index d1311c27b..e6f7acc9f 100644 --- a/extensions/dsh-browser/manifest.json +++ b/extensions/dsh-browser/manifest.json @@ -3,8 +3,9 @@ "name": "__MSG_extensionName__", "description": "__MSG_extensionDescription__", "default_locale": "en", - "version": "0.1.4", + "version": "0.1.5", "minimum_chrome_version": "116", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqzAPQcLNodZ9dwaOafZpmUMtFRDi7nO93D3pft0ptPa+N3/bNzFptRJvZOP+39F+4hWS92GY1pYOjfqgtydcRtlkBuoVylPl0bgqKcIB1Cdyug7G5HJmRzXSpuhl1/mgbJfKE68Glenc3eDFLESQDEwE1wPDaLaCjvP+d3HVVJZcQtIRzUzKpvt21ACG8A7lmw6j3felQaw9AJTWRUehklV0gwcM09HLYCs0Pgr07HCgyzWuMZxpdkiEARCJ7D6iY//vk40/wX5X3OtgMypJpXtqsGAtUsS+j6S22EGvR5Cm3gy9GZyxYdqCLfjsZ1BjRsBSDTAEgmU3DXQ58brhVQIDAQAB", "permissions": [ "sidePanel", "storage", diff --git a/extensions/dsh-browser/package.json b/extensions/dsh-browser/package.json index d7d949c5b..7193897ae 100644 --- a/extensions/dsh-browser/package.json +++ b/extensions/dsh-browser/package.json @@ -1,7 +1,7 @@ { "name": "dsh-browser-extension", "description": "Chrome and Firefox MV3 extension: sidebar chat with a local dsh instance and text-only read/operate of a user-controlled tab through the dsh browser bridge", - "version": "0.1.4", + "version": "0.1.5", "author": "Yuxiang Lin", "license": "MIT", "private": true, diff --git a/packages/browser/bridge-browser/README.md b/packages/browser/bridge-browser/README.md index 78eaffbda..b388e2ad5 100644 --- a/packages/browser/bridge-browser/README.md +++ b/packages/browser/bridge-browser/README.md @@ -11,6 +11,7 @@ The **browser-operation bridge** for dsh: mounts a token-authenticated WebSocket | Key | Type | Default | Description | |---|---|---|---| | `token` | `string` | generated | Fixed bearer token. When absent, a token is generated on first boot, persisted at `~/.dsh/ext-bridge-token` (chmod 0600), and printed in the boot log. | +| `trustedExtensionOrigins` | `string[]` | shipped extension id | Exact `chrome-extension://` origins allowed to skip the token on loopback. The default pins the id derived from the extension manifest `key` (public — the pin stops incidental cross-extension access, not deliberate key-cloning impersonation); every other origin must present the token. `[]` disables the exemption (hardened mode: pair the token). Invalid entries fail startup. | | `toolTimeoutMs` | `number` | 90000 | Per-tool-call budget, leaving time for the extension's 60-second approval window. | | `snapshotMaxChars` | `number` | 32000 | Upper bound on one rendered snapshot's characters, minimum 500 (also negotiated to the extension via `hello.ok` caps). | | `maxInteractiveItems` | `number` | 60 | Upper bound on interactive inventory items per snapshot. | @@ -43,13 +44,14 @@ The workspace pins dsh 0.2.0-rc.2, the minimum supported runtime. Older DSH rele npx @deepseek-ai/dsh@0.2.0-rc.2 web ``` -The installer copies the unpacked extension to `~/.dsh/browser-extension` and opens `chrome://extensions`. Load that stable directory in Chrome and use the side panel. Loopback connections are discovered automatically and require no token entry; non-loopback deployments still require the configured bearer token. +The installer copies the unpacked extension to `~/.dsh/browser-extension` and opens `chrome://extensions`. Load that stable directory in Chrome and use the side panel. The shipped extension carries a stable manifest `key`, so its id — and therefore its token-exempt loopback access — stays constant across reinstalls and updates. Updating from a pre-0.1.5 build changes the id once (settings are stored per id): re-enter the bridge address and token in the panel if you had configured a remote bridge. Other installed extensions are not exempt and must present the configured bearer token; non-loopback deployments always require it. ## Security model - The bridge route lives **outside** the `/api` trust fence (which only guards client-connection's routes), so it carries its own bearer-token authentication: the first frame must be `hello` with the token within 5s, verified in constant time. Failed auth closes the socket. - Gateway methods the `/api` carrier pins to loopback (`settings.*`, `credentials.*`, `host.pickDirectory`, `host.openPath`) are refused for non-loopback remotes **even with a valid token** — defense in depth for `--host 0.0.0.0` deployments. - One active connection at a time; a new authenticated socket replaces the previous one. +- Zero-config loopback skips the token only for origins in `trustedExtensionOrigins` (default: the shipped extension's key-derived id). The manifest `key` is public, so this pins *incidental* access — an extension that deliberately copies the key inherits the id; set `trustedExtensionOrigins: []` and pair the token when hostile extensions are in scope. Origin checks bind browser contexts only — any local process can spoof an `Origin` header (and read the token file), so the bearer token remains the actual boundary. The exemption is loopback-only, but a reverse proxy terminating on 127.0.0.1 (`tailscale serve`, `ssh -L`, nginx) makes proxied clients appear as loopback — use hardened mode for such topologies. - The bridge is a confused-deputy boundary, not a general auth layer: never expose `dsh web --host 0.0.0.0` on untrusted networks. - Extracted page text is marked as untrusted model input. Page reads honor the extension's ask/auto/off policy, while state-changing tools require an origin-scoped side-panel decision and fail closed without a panel. Same-origin repetition can be trusted for the current panel session; permanent trust remains an explicit setting. diff --git a/packages/browser/bridge-browser/README.zh.md b/packages/browser/bridge-browser/README.zh.md index b41b63054..4d2913a33 100644 --- a/packages/browser/bridge-browser/README.zh.md +++ b/packages/browser/bridge-browser/README.zh.md @@ -11,6 +11,7 @@ dsh 的**浏览器操作桥**:在宿主 webserver 上挂载一个 **token 认 | 键 | 类型 | 默认 | 说明 | |---|---|---|---| | `token` | `string` | 自动生成 | 固定 bearer token。缺省时首次启动生成,写入 `~/.dsh/ext-bridge-token`(0600)并打印在启动日志。 | +| `trustedExtensionOrigins` | `string[]` | 随附扩展 id | 允许在回环免 token 的精确 `chrome-extension://` Origin 列表。默认钉住由扩展 manifest `key` 派生的 id(key 是公开的——该钉住只防误连,不防刻意复制 key 的冒充);其它 Origin 必须携带 token。`[]` 表示完全关闭豁免(加固模式:强制 token 配对)。非法条目会导致启动失败。 | | `toolTimeoutMs` | `number` | 90000 | 单次工具调用预算,为扩展的 60 秒审批窗口预留时间。 | | `snapshotMaxChars` | `number` | 32000 | 单次快照渲染字符上限,最小为 500(经 `hello.ok` caps 协商给扩展)。 | | `maxInteractiveItems` | `number` | 60 | 单次快照交互清单条数上限。 | @@ -43,13 +44,14 @@ cd $HOME\.dsh\dsh-browser; pnpm start npx @deepseek-ai/dsh@0.2.0-rc.2 web ``` -安装器会把已解压扩展复制到 `~/.dsh/browser-extension` 并打开 `chrome://extensions`。在 Chrome 中加载这个稳定目录,然后使用侧边栏。扩展会自动发现回环连接,无需输入 token;非回环部署仍需要配置的 bearer token。 +安装器会把已解压扩展复制到 `~/.dsh/browser-extension` 并打开 `chrome://extensions`。在 Chrome 中加载这个稳定目录,然后使用侧边栏。随附扩展带有固定 manifest `key`,其 id——以及随之的回环免 token 访问——在重装和升级后保持不变。从 0.1.5 之前的版本升级时 id 会变更一次(设置按 id 存储):如曾配置远程桥地址与 token,请在面板中重新填写。其它已安装扩展不在豁免之列,必须携带配置的 bearer token;非回环部署始终需要 token。 ## 安全模型 - 桥路径在 `/api` 信任栅栏**之外**(栅栏只罩 client-connection 注册的路由),因此自带 bearer token 认证:首帧必须是 `hello`(5 秒内),常量时间比对,失败即断开。 - `/api` 载体钉在回环上的方法(`settings.*`、`credentials.*`、`host.pickDirectory`、`host.openPath`)对非回环来源**即使 token 正确也拒绝**——对 `--host 0.0.0.0` 部署的纵深防御。 - 同一时刻仅一个活动连接,新认证连接顶替旧连接。 +- 零配置回环只对 `trustedExtensionOrigins` 中的 Origin 免 token(默认:随附扩展由 key 派生的 id)。manifest `key` 是公开的,因此这只*防*误连——刻意复制 key 的扩展会得到相同 id;需要抵御恶意扩展时请设置 `trustedExtensionOrigins: []` 并强制 token 配对。Origin 校验只约束浏览器上下文——任何本地进程都能伪造 `Origin` 头(也能读取 token 文件),真正的边界始终是 bearer token。豁免仅限回环,但在 127.0.0.1 上终结连接的反向代理(`tailscale serve`、`ssh -L`、nginx)会让代理客户端表现为回环——此类拓扑请使用加固模式。 - 桥是 confused-deputy 边界而非通用认证层:不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。 - 抽取的页面文字会标记为模型的不可信输入。页面读取遵循扩展的询问/自动/关闭策略;状态变更工具必须经过按 origin 的侧边栏决策,没有侧边栏时失败关闭。同源后续操作可只在当前侧栏会话中临时信任,永久信任仍需显式设置。 diff --git a/packages/browser/bridge-browser/src/extension-origins.ts b/packages/browser/bridge-browser/src/extension-origins.ts new file mode 100644 index 000000000..474a0f484 --- /dev/null +++ b/packages/browser/bridge-browser/src/extension-origins.ts @@ -0,0 +1,41 @@ +/** + * Trusted extension identity for the bridge's zero-config loopback mode. + * + * Chromium derives an unpacked extension's id from the manifest `key` + * (base64 DER SubjectPublicKeyInfo): the first 16 bytes of SHA-256 mapped + * onto `a`–`p`. Without a `key` the id is derived from the install path and + * differs on every machine — useless as an identity. The shipped extension + * therefore pins a stable `key` in `extensions/dsh-browser/manifest.json`, + * and the bridge pins the matching origin here. Forks and custom builds + * override the list through the `trustedExtensionOrigins` plugin config. + * + * @module @yuxianglin/dsh-bridge-browser/src/extension-origins + */ + +import { createHash } from 'node:crypto' + +/** + * Stable Chromium extension id of the shipped dsh-browser extension + * (SHA-256 over the manifest key's DER SPKI, first 16 bytes, hex→a-p). + */ +export const STABLE_CHROME_EXTENSION_ID = 'ampcoeplakeelcoengijbfbhjnlbdign' + +/** Default loopback token exemption: exactly the shipped extension. */ +export const DEFAULT_TRUSTED_EXTENSION_ORIGINS: readonly string[] = [ + `chrome-extension://${STABLE_CHROME_EXTENSION_ID}`, +] + +/** + * Derive the Chromium extension id from a manifest `key` value. + * @param key - base64 DER SubjectPublicKeyInfo from the manifest. + * @returns the 32-char a-p extension id, or undefined when the key is empty + * or does not decode to a DER SEQUENCE (lenient base64 decoding would + * otherwise mint a plausible-but-wrong id from truncated garbage). + */ +export function chromeExtensionIdFromKey(key: string): string | undefined { + const der = Buffer.from(key, 'base64') + // 0x30 = DER SEQUENCE tag; every SPKI starts with it. + if (der.length === 0 || der[0] !== 0x30) return undefined + const hex = createHash('sha256').update(der).digest('hex').slice(0, 32) + return [...hex].map((nibble) => 'abcdefghijklmnop'[Number.parseInt(nibble, 16)]).join('') +} diff --git a/packages/browser/bridge-browser/src/index.ts b/packages/browser/bridge-browser/src/index.ts index 1d77aa767..4adb0c1fa 100644 --- a/packages/browser/bridge-browser/src/index.ts +++ b/packages/browser/bridge-browser/src/index.ts @@ -39,6 +39,7 @@ import { withSessionDeferral } from './session-deferral.ts' import { withSessionWorkspace } from './session-workspace.ts' import { purgeSessionFiles, type SessionPurgeDeps } from './session-purge.ts' import { resolveToken } from './token.ts' +import { DEFAULT_TRUSTED_EXTENSION_ORIGINS } from './extension-origins.ts' import { createRemoteHostApi, type HostConnectionLike, @@ -71,6 +72,16 @@ const DEFAULT_DEFER_SESSION_CREATE = true export interface Config { /** Fixed bearer token. When absent, a token is generated on first boot and persisted under the dsh home (0600). */ token?: string + /** + * Exact `chrome-extension://` origins that may skip the bearer token on + * loopback. Defaults to the shipped extension's stable id (manifest `key`). + * Every other origin — unlisted extensions and all non-loopback clients — + * must present the token. `[]` disables the exemption entirely (hardened + * mode: pair the token). Entries must be exact `chrome-extension://` + * origins; a web origin here would re-grant the trust this list exists to + * remove, so anything else fails loudly at startup. + */ + trustedExtensionOrigins?: string[] /** Per-tool-call timeout in ms. Defaults to 90000. */ toolTimeoutMs?: number /** Upper bound on one snapshot's rendered characters. Defaults to 32000; minimum 500. */ @@ -85,6 +96,7 @@ export interface Config { export const Config: z = z.object({ token: z.string(), + trustedExtensionOrigins: z.array(z.string()).default([...DEFAULT_TRUSTED_EXTENSION_ORIGINS]), toolTimeoutMs: z.number().step(1).min(1).default(DEFAULT_TOOL_TIMEOUT_MS), snapshotMaxChars: z.number().step(1).min(MIN_SNAPSHOT_MAX_CHARS).default(DEFAULT_SNAPSHOT_MAX_CHARS), maxInteractiveItems: z.number().step(1).min(1).default(DEFAULT_MAX_INTERACTIVE_ITEMS), @@ -102,6 +114,9 @@ export function assertPositiveInteger(name: string, value: number): void { } } +/** Origin shape the loopback exemption accepts: exactly `chrome-extension://<32-char a-p id>`. */ +const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/ + /** * Apply defaults and direct-call validation at the plugin boundary. * @param config - Loader-resolved or directly supplied plugin configuration. @@ -115,6 +130,7 @@ export function resolveConfig(config: Config): ResolvedConfig { maxInteractiveItems: config.maxInteractiveItems ?? DEFAULT_MAX_INTERACTIVE_ITEMS, sessionWorkspacePath: config.sessionWorkspacePath ?? DEFAULT_SESSION_WORKSPACE_PATH, deferSessionCreate: config.deferSessionCreate ?? DEFAULT_DEFER_SESSION_CREATE, + trustedExtensionOrigins: config.trustedExtensionOrigins ?? [...DEFAULT_TRUSTED_EXTENSION_ORIGINS], } assertPositiveInteger('toolTimeoutMs', resolved.toolTimeoutMs) assertPositiveInteger('snapshotMaxChars', resolved.snapshotMaxChars) @@ -122,6 +138,13 @@ export function resolveConfig(config: Config): ResolvedConfig { throw new Error(`bridge-browser: snapshotMaxChars must be at least ${MIN_SNAPSHOT_MAX_CHARS}`) } assertPositiveInteger('maxInteractiveItems', resolved.maxInteractiveItems) + for (const origin of resolved.trustedExtensionOrigins) { + if (!EXTENSION_ORIGIN_PATTERN.test(origin)) { + throw new Error( + `bridge-browser: trustedExtensionOrigins entries must be exact chrome-extension:// origins (32 chars a-p); received "${origin}"`, + ) + } + } return resolved } @@ -216,6 +239,7 @@ function mountBridge( const server = new BridgeServer({ token: tokenRes.token, + trustedExtensionOrigins: resolved.trustedExtensionOrigins, api, toolTimeoutMs: resolved.toolTimeoutMs, caps: { @@ -237,8 +261,9 @@ function mountBridge( // Zero-config discovery endpoint: the extension fetches this to learn the // bridge WebSocket URL without any manual configuration. The URL carries no - // secret (loopback connections skip the token); non-loopback deployments - // keep requiring the token on the WS itself. + // secret; loopback connections still authenticate either by the pinned + // extension origin (trustedExtensionOrigins) or the bearer token, and + // non-loopback deployments keep requiring the token on the WS itself. const configRoute: WebRoute = { kind: 'exact', path: BRIDGE_CONFIG_PATH, @@ -276,6 +301,11 @@ function mountBridge( ? `browser bridge: new token generated and persisted at ${tokenRes.file} (chmod 0600); connect the extension and paste it in its settings` : `browser bridge: using token from ${tokenRes.file}`, ) + ctx.logger.info( + resolved.trustedExtensionOrigins.length === 0 + ? 'browser bridge: no extension origin is token-exempt (hardened mode); every client must present the token' + : `browser bridge: token-exempt loopback origins: ${resolved.trustedExtensionOrigins.join(', ')}`, + ) ctx.logger.info(`browser bridge: listening on ${BRIDGE_PATH}`) } diff --git a/packages/browser/bridge-browser/src/server.ts b/packages/browser/bridge-browser/src/server.ts index 1ee246bcf..9f9378b26 100644 --- a/packages/browser/bridge-browser/src/server.ts +++ b/packages/browser/bridge-browser/src/server.ts @@ -83,6 +83,14 @@ export class BridgeToolError extends Error { export interface BridgeServerDeps { /** Bearer token the extension must present in `hello`. */ token: string + /** + * Exact `chrome-extension://` origins allowed to skip the bearer token + * on loopback. Trust is pinned per extension id, NOT by scheme prefix: any + * other installed extension can present its own chrome-extension:// Origin, + * so an unlisted origin must authenticate with the token. Fail-closed when + * empty (no origin receives the exemption). + */ + trustedExtensionOrigins?: readonly string[] /** Active dsh Host adapter used for unary calls, events, and waterfalls. */ api: BrowserHostApi /** Default per-tool-call timeout in ms. */ @@ -151,12 +159,18 @@ export function messageToText(data: Buffer | ArrayBuffer | Buffer[]): string { */ export class BridgeServer { private readonly wss = new WebSocketServer({ noServer: true }) + private readonly trustedOrigins: ReadonlySet private current: ReadyConnection | null = null private readonly pendingTools = new Map() private readonly orderedSessionRpcs = new Map>() private closed = false - constructor(private readonly deps: BridgeServerDeps) {} + constructor(private readonly deps: BridgeServerDeps) { + // Built in the body (not as a field initializer): with + // useDefineForClassFields, initializers run before parameter properties + // are assigned, so `this.deps` is not available there. + this.trustedOrigins = new Set(deps.trustedExtensionOrigins) + } /** * Handle one HTTP upgrade for the bridge path. @@ -291,19 +305,22 @@ export class BridgeServer { ws.close(1008, 'hello first') return } - // Zero-config local mode: loopback sockets skip the token (the - // extension auto-discovers the bridge and connects without setup). - // WebSockets have no same-origin policy, so a malicious page could - // open a cross-origin socket to 127.0.0.1 with a loopback remote — - // the loopback shortcut therefore requires a chrome-extension:// - // Origin (only extension contexts can present one; pages cannot - // forge the header). Firefox moz-extension:// origins contain a - // per-install UUID rather than the manifest's stable Gecko ID, so - // they are not an identity boundary and must present the bearer token. + // Zero-config local mode: loopback sockets from the TRUSTED + // extension skip the token (the extension auto-discovers the bridge + // and connects without setup). WebSockets have no same-origin + // policy, so a malicious page could open a cross-origin socket to + // 127.0.0.1 with a loopback remote — pages cannot forge an Origin + // header, but ANY installed extension can present its own + // chrome-extension:// Origin. The exemption is therefore an + // exact-match allowlist of pinned extension ids; every other + // origin (unlisted extensions included) must present the bearer + // token. Firefox moz-extension:// origins contain a per-install + // UUID rather than the manifest's stable Gecko ID, so they are not + // an identity boundary and must present the bearer token. // Non-loopback remotes must also present the bearer token. const loopbackNoToken = isLoopbackAddress(remoteAddress) && typeof origin === 'string' - && origin.startsWith('chrome-extension://') + && this.trustedOrigins.has(origin) if (!loopbackNoToken && !verifyToken(this.deps.token, frame.token)) { ws.close(4002, 'bad token') return diff --git a/packages/browser/bridge-browser/tests/composition.spec.ts b/packages/browser/bridge-browser/tests/composition.spec.ts index 34961fbe0..65e039828 100644 --- a/packages/browser/bridge-browser/tests/composition.spec.ts +++ b/packages/browser/bridge-browser/tests/composition.spec.ts @@ -139,6 +139,11 @@ async function loadComposition(): Promise<{ ctx: Context; configPath: string; po `- name: '${BRIDGE}'`, ' config:', ` token: '${TOKEN}'`, + // The harness client presents this fake extension origin; the config + // override replaces the shipped-extension default with it so the real + // loader path (schema default → resolveConfig → BridgeServer) is what + // grants the exemption here. + ` trustedExtensionOrigins: ['${EXT_ORIGIN}']`, ` sessionWorkspacePath: '${join(root, 'browser-sessions')}'`, // This spec drives the raw gateway chain (create → real session); the // deferred-creation behavior is covered by its focused wrapper spec. @@ -179,8 +184,8 @@ async function loadComposition(): Promise<{ ctx: Context; configPath: string; po return { ctx: context, configPath, port: web.port } } -/** 扩展上下文 Origin(回环免 token 的必要条件)。 */ -const EXT_ORIGIN = 'chrome-extension://test-extension-id' +/** 扩展上下文 Origin(回环免 token 的必要条件)。必须是合法形状(32 位 a-p id):resolveConfig 会校验。 */ +const EXT_ORIGIN = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' function connect(port: number): Promise<{ ws: WebSocket diff --git a/packages/browser/bridge-browser/tests/e2e/bridge-extension.e2e.spec.ts b/packages/browser/bridge-browser/tests/e2e/bridge-extension.e2e.spec.ts index fed43d903..cb0802b38 100644 --- a/packages/browser/bridge-browser/tests/e2e/bridge-extension.e2e.spec.ts +++ b/packages/browser/bridge-browser/tests/e2e/bridge-extension.e2e.spec.ts @@ -2,9 +2,15 @@ * Browser smoke: the built MV3 extension connects through a real WebSocket to * the migrated bridge carrier. dsh 0.1.2 Remote semantics are covered by * remote-host-api.spec; this test deliberately owns no pre-0.1.2 Host shim. + * + * The zero-config case is the load-bearing one for the origin allowlist: the + * built extension carries the stable manifest `key`, so the id Chromium + * derives MUST be the pinned default and the tokenless loopback hello MUST be + * accepted purely on that origin. The second case exercises the paired-token + * path (Firefox-style) against the same server. */ -import { existsSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' @@ -13,6 +19,7 @@ import { join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { chromium, type BrowserContext } from 'playwright-core' import { BridgeServer } from '../../src/server.ts' +import { DEFAULT_TRUSTED_EXTENSION_ORIGINS, STABLE_CHROME_EXTENSION_ID } from '../../src/extension-origins.ts' import type { BrowserHostApi, HostRpcCall, HostRpcResult } from '../../src/host-api.ts' const TOKEN = 'e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0' @@ -23,7 +30,12 @@ function chromiumExecutable(): string | undefined { if (fromEnv !== undefined && existsSync(fromEnv)) return fromEnv const cacheRoot = join(process.env.HOME ?? '', 'Library', 'Caches', 'ms-playwright') if (!existsSync(cacheRoot)) return undefined - for (const dir of ['chromium-1217', 'chromium-1226', 'chromium-1181']) { + // Any installed playwright chromium build, newest first — the exact build + // number changes with every playwright-core bump. + const dirs = readdirSync(cacheRoot) + .filter(dir => /^chromium-\d+$/.test(dir)) + .sort((a, b) => Number(b.slice('chromium-'.length)) - Number(a.slice('chromium-'.length))) + for (const dir of dirs) { for (const candidate of [ join(cacheRoot, dir, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'), join(cacheRoot, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), @@ -72,6 +84,10 @@ beforeAll(async () => { } bridge = new BridgeServer({ token: TOKEN, + // The real allowlist default: only the shipped extension's stable id is + // token-exempt on loopback. The tests below prove the id derivation and + // the tokenless hello against this exact list. + trustedExtensionOrigins: DEFAULT_TRUSTED_EXTENSION_ORIGINS, api, toolTimeoutMs: 10_000, caps: { textOnly: true, snapshotMaxChars: 32_000, maxInteractiveItems: 60 }, @@ -96,6 +112,9 @@ beforeAll(async () => { executablePath: executable, channel: 'chromium', headless: true, + // The panel renders zh or en copy from navigator.languages; the + // selectors below are zh, so pin the locale regardless of host Chrome. + locale: 'zh-CN', args: [ `--disable-extensions-except=${EXTENSION_DIR}`, `--load-extension=${EXTENSION_DIR}`, @@ -113,19 +132,42 @@ afterAll(async () => { }) describe('extension ↔ migrated bridge smoke', () => { - it('connects, negotiates caps, and initializes a Session through the private bridge protocol', { timeout: 60_000 }, async () => { - if (executable === undefined) { - console.warn('SKIP: no usable Chromium') - return - } - if (browser === undefined || port === undefined) { - console.warn('SKIP: extension dist not built') - return - } + it('loads the shipped build under the pinned extension id and connects tokenless via the origin allowlist', { timeout: 60_000 }, async (ctx) => { + if (executable === undefined) return ctx.skip() + if (browser === undefined || port === undefined) return ctx.skip() let worker = browser.serviceWorkers()[0] worker ??= await browser.waitForEvent('serviceworker', { timeout: 30_000 }) const extensionId = new URL(worker.url()).host + // The manifest `key` must hash to exactly the pinned default origin — + // this is what makes the tokenless hello below meaningful. + expect(extensionId).toBe(STABLE_CHROME_EXTENSION_ID) + + const panel = await browser.newPage() + await panel.goto(`chrome-extension://${extensionId}/panel/index.html`) + await panel.waitForSelector('header.topbar', { timeout: 15_000 }) + + // Address only, token LEFT EMPTY: the hello must be accepted purely on + // the pinned chrome-extension:// origin over loopback. + await panel.click('button[aria-label="打开设置"]') + await panel.fill('input[placeholder*="自动检测"]', `ws://127.0.0.1:${String(port)}`) + await panel.fill('input[type="password"]', '') + await panel.click('text=保存并连接') + + await expect.poll( + () => panel.locator('.connection').textContent(), + { timeout: 30_000 }, + ).toContain('已连接') + await panel.close() + }) + + it('connects with an explicitly paired token and initializes a Session through the private bridge protocol', { timeout: 60_000 }, async (ctx) => { + if (executable === undefined) return ctx.skip() + if (browser === undefined || port === undefined) return ctx.skip() + + const worker = browser.serviceWorkers()[0] + if (worker === undefined) return ctx.skip() + const extensionId = new URL(worker.url()).host const panel = await browser.newPage() await panel.goto(`chrome-extension://${extensionId}/panel/index.html`) await panel.waitForSelector('header.topbar', { timeout: 15_000 }) diff --git a/packages/browser/bridge-browser/tests/extension-origins.spec.ts b/packages/browser/bridge-browser/tests/extension-origins.spec.ts new file mode 100644 index 000000000..a70188789 --- /dev/null +++ b/packages/browser/bridge-browser/tests/extension-origins.spec.ts @@ -0,0 +1,43 @@ +/** + * Trusted-extension identity drift guard: the manifest `key` must keep + * hashing to the origin the bridge pins by default. Without this check a + * regenerated key would silently break zero-config loopback for every + * shipped install (the extension id would change and fall off the + * allowlist, forcing token pairing). + */ + +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + chromeExtensionIdFromKey, + DEFAULT_TRUSTED_EXTENSION_ORIGINS, + STABLE_CHROME_EXTENSION_ID, +} from '../src/extension-origins.ts' + +const manifestPath = resolve(import.meta.dirname, '../../../../extensions/dsh-browser/manifest.json') + +describe('trusted extension origins', () => { + it('pins a well-formed 32-char a-p extension id as the sole default', () => { + expect(STABLE_CHROME_EXTENSION_ID).toMatch(/^[a-p]{32}$/) + expect(DEFAULT_TRUSTED_EXTENSION_ORIGINS).toEqual([`chrome-extension://${STABLE_CHROME_EXTENSION_ID}`]) + }) + + it('derives the pinned id from the extension manifest key (drift guard)', () => { + // The packed npm package ships without the monorepo's extensions/ tree; + // the guard only applies where the manifest exists. + if (!existsSync(manifestPath)) { + console.warn(`[skip] manifest not found outside the monorepo: ${manifestPath}`) + return + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { key?: string } + expect(manifest.key, 'extensions/dsh-browser/manifest.json must carry the key pinned in extension-origins.ts').toBeDefined() + expect(chromeExtensionIdFromKey(manifest.key as string)).toBe(STABLE_CHROME_EXTENSION_ID) + }) + + it('refuses empty or non-SPKI keys instead of deriving a bogus id', () => { + expect(chromeExtensionIdFromKey('')).toBeUndefined() + // Decodes to non-empty bytes that do not start with a DER SEQUENCE tag. + expect(chromeExtensionIdFromKey('AAAA')).toBeUndefined() + }) +}) diff --git a/packages/browser/bridge-browser/tests/index.spec.ts b/packages/browser/bridge-browser/tests/index.spec.ts index 08d0ee652..ca6153c3b 100644 --- a/packages/browser/bridge-browser/tests/index.spec.ts +++ b/packages/browser/bridge-browser/tests/index.spec.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { apply, assertPositiveInteger, Config, resolveConfig } from '../src/index.ts' +import { DEFAULT_TRUSTED_EXTENSION_ORIGINS } from '../src/extension-origins.ts' /** Minimal context stub: apply only needs the services at registration time. */ function stubContext(): Context { @@ -58,8 +59,10 @@ describe('config', () => { ...VALID, sessionWorkspacePath: dshHomePath('browser-sessions'), deferSessionCreate: true, + trustedExtensionOrigins: [...DEFAULT_TRUSTED_EXTENSION_ORIGINS], }) expect(new Config().sessionWorkspacePath).toBe(dshHomePath('browser-sessions')) + expect(new Config().trustedExtensionOrigins).toEqual([...DEFAULT_TRUSTED_EXTENSION_ORIGINS]) }) it('preserves explicit values and the empty-string workspace opt-out', () => { @@ -70,6 +73,7 @@ describe('config', () => { maxInteractiveItems: 3, sessionWorkspacePath: '', deferSessionCreate: false, + trustedExtensionOrigins: ['chrome-extension://abcdefghijklmnopabcdefghijklmnop'], })).toEqual({ token: 'fixed', toolTimeoutMs: 1, @@ -77,9 +81,23 @@ describe('config', () => { maxInteractiveItems: 3, sessionWorkspacePath: '', deferSessionCreate: false, + trustedExtensionOrigins: ['chrome-extension://abcdefghijklmnopabcdefghijklmnop'], }) expect(new Config({ sessionWorkspacePath: '' }).sessionWorkspacePath).toBe('') }) + + it('rejects trustedExtensionOrigins entries that are not extension origins', () => { + // A web origin here would grant that site's pages tokenless loopback + // access — the exact trust Finding 1 removed. Malformed entries fail + // loudly instead of silently disabling zero-config. + for (const bad of ['https://intranet.example', 'chrome-extension://short', 'chrome-extension://abc/', '']) { + expect(() => resolveConfig({ trustedExtensionOrigins: [bad] }), bad).toThrow(/trustedExtensionOrigins/) + } + }) + + it('accepts an explicit empty allowlist (hardened fail-closed mode)', () => { + expect(resolveConfig({ trustedExtensionOrigins: [] })).toMatchObject({ trustedExtensionOrigins: [] }) + }) }) describe('apply', () => { diff --git a/packages/browser/bridge-browser/tests/server.spec.ts b/packages/browser/bridge-browser/tests/server.spec.ts index c255e5b48..8e24db382 100644 --- a/packages/browser/bridge-browser/tests/server.spec.ts +++ b/packages/browser/bridge-browser/tests/server.spec.ts @@ -45,6 +45,7 @@ async function startBridge(overrides: Partial {}), + trustedExtensionOrigins: [EXT_ORIGIN], ...overrides, }) const server = createServer() @@ -129,7 +130,7 @@ describe('BridgeServer', () => { ws.close() }) - it('accepts loopback connections without a token when Origin is an extension (zero-config mode)', async () => { + it('accepts loopback connections without a token when Origin is the trusted extension (zero-config mode)', async () => { const h = await startBridge() harnesses.push(h) const { ws, frames } = await connect(h.url, EXT_ORIGIN) @@ -139,6 +140,54 @@ describe('BridgeServer', () => { ws.close() }) + it('rejects loopback connections without a token when Origin is an unlisted extension', async () => { + // Any other installed extension must not inherit the trusted extension's + // token exemption just by presenting a chrome-extension:// Origin. + const h = await startBridge() + harnesses.push(h) + const { ws, done } = await connect(h.url, 'chrome-extension://some-other-extension') + send(ws, { t: 'hello', token: '', caps: CAPS }) + await Promise.race([ + done, + new Promise((_, reject) => { setTimeout(() => reject(new Error('unlisted extension origin was not rejected')), 1_000) }), + ]) + expect(ws.readyState).toBe(WebSocket.CLOSED) + expect(h.bridge.hasConnection()).toBe(false) + }) + + it('accepts an unlisted extension origin that presents the token (custom builds)', async () => { + const h = await startBridge() + harnesses.push(h) + const { ws, frames } = await connect(h.url, 'chrome-extension://some-other-extension') + send(ws, { t: 'hello', token: TOKEN, caps: CAPS }) + await waitFor(() => frames.some((f) => f.t === 'hello.ok')) + expect(frames.find((f) => f.t === 'hello.ok')).toBeDefined() + ws.close() + }) + + it('rejects a trusted extension origin from a non-loopback remote without the token', async () => { + // Origin trust alone is never sufficient: the exemption is loopback-only, + // so a remote client spoofing/presenting the pinned Origin still needs the token. + const h = await startBridge({ remoteAddressOverride: '192.168.1.5' }) + harnesses.push(h) + const { ws, done } = await connect(h.url, EXT_ORIGIN) + send(ws, { t: 'hello', token: '', caps: CAPS }) + await done + expect(ws.readyState).toBe(WebSocket.CLOSED) + expect(h.bridge.hasConnection()).toBe(false) + }) + + it('fails closed when no origin is trusted (hardened mode)', async () => { + // Explicit empty allowlist: even the pinned-origin shape gets no exemption. + const h = await startBridge({ trustedExtensionOrigins: [] }) + harnesses.push(h) + const { ws, done } = await connect(h.url, EXT_ORIGIN) + send(ws, { t: 'hello', token: '', caps: CAPS }) + await done + expect(ws.readyState).toBe(WebSocket.CLOSED) + expect(h.bridge.hasConnection()).toBe(false) + }) + it('requires a token from Firefox extension origins because their UUID is not an extension identity', async () => { const h = await startBridge() harnesses.push(h) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index de5a101b3..b2e5fa899 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -475,6 +475,7 @@ if (-not $ChromeOpened) { # contains them has to use single-quoted literals or it splits into extra arguments. if ($IsUpdate) { Write-Pair "检测到已有扩展目录,文件已安全更新。" "Existing extension directory detected; its files were updated safely." + Write-Pair "若从 0.1.5 之前的版本升级:扩展 id 会因固定 manifest key 变更一次;如曾配置远程桥地址/token,请在面板中重新填写。" "Updating from pre-0.1.5: the extension id changes once (stable manifest key); re-enter the bridge address/token in the panel if you had configured a remote bridge." Write-Pair "打开 Google Chrome(注意不是 Edge/Firefox):" "Open Google Chrome (not Edge/Firefox):" Write-Host '' Write-Pair " 地址栏输入 chrome://extensions" " Type chrome://extensions in the address bar" diff --git a/scripts/install.sh b/scripts/install.sh index 2d2638ac7..1f61fec00 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -373,6 +373,7 @@ if [ "$CHROME_OPENED" -ne 1 ]; then fi if [ "$IS_UPDATE" -eq 1 ]; then print_pair "检测到已有扩展目录,文件已安全更新。" "Existing extension directory detected; its files were updated safely." + print_pair "若从 0.1.5 之前的版本升级:扩展 id 会因固定 manifest key 变更一次;如曾配置远程桥地址/token,请在面板中重新填写。" "Updating from pre-0.1.5: the extension id changes once (stable manifest key); re-enter the bridge address/token in the panel if you had configured a remote bridge." print_pair "打开 Google Chrome(注意不是 Edge/Firefox):" "Open Google Chrome (not Edge/Firefox):" printf '\n' print_pair " 地址栏输入 chrome://extensions" " Type chrome://extensions in the address bar"