From 99f9e200af1ac0304f2351aa03f0e13181d114cf Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Sun, 20 Sep 2026 21:47:32 +0800 Subject: [PATCH 1/2] fix(browser): support remote --host 0.0.0.0 bridge clients Derive /ext/bridge-config wsUrl from the request Host and allow EXT_CONNECT_SRC to append LAN origins to the extension connect-src CSP at build time so remote deployments match the documented flow. --- README.md | 15 +++++ README.zh.md | 15 +++++ extensions/dsh-browser/scripts/build.mjs | 3 + extensions/dsh-browser/src/connect-src.ts | 21 ++++++ .../dsh-browser/tests/firefox-build.spec.ts | 14 ++++ extensions/dsh-browser/vite.shared.ts | 24 ++++++- packages/browser/bridge-browser/README.md | 1 + packages/browser/bridge-browser/README.zh.md | 1 + .../browser/bridge-browser/src/bridge-url.ts | 65 +++++++++++++++++++ packages/browser/bridge-browser/src/index.ts | 38 +++++++++-- .../bridge-browser/tests/bridge-url.spec.ts | 28 +++++++- 11 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 extensions/dsh-browser/src/connect-src.ts diff --git a/README.md b/README.md index 84ed56450..e21ae9da8 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,21 @@ Local Chrome use requires no configuration; Firefox requires the local bridge to - Verify the bridge is loaded: open `http://127.0.0.1:3080/ext/bridge-config`. It should return JSON such as `{"wsUrl":"ws://127.0.0.1:3080/ext/bridge"}`. If it returns a web page instead of JSON, the running dsh predates the bridge registration — restart dsh and refresh the page; the extension reconnects on its own. - The extension probes ports 3080, 3081, 3090, and 14389 automatically. If dsh runs on another port — or you use a remote `--host 0.0.0.0` deployment — set the address (and bridge token) in the panel settings. Firefox always requires the token. +**Remote `--host 0.0.0.0` / LAN deployments** + +Two pieces must line up: + +1. **Bridge discovery URL** — `/ext/bridge-config` now answers with a `wsUrl` derived from the request `Host` (and `X-Forwarded-*` when present), so a client that reaches `http://192.168.2.185:3080/ext/bridge-config` receives `ws://192.168.2.185:3080/ext/bridge` instead of a useless loopback address. +2. **Extension CSP** — the published manifests only allow `connect-src` to loopback. Rebuild the extension with extra origins before loading it on the remote client: + +```sh +EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build +# or Firefox: +EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build:firefox +``` + +Then load `extensions/dsh-browser/dist/` (or `dist-firefox/`), set the panel bridge URL to `ws://192.168.2.185:3080/ext/bridge`, and paste the bridge token from `~/.dsh/ext-bridge-token` on the host. Do not expose `dsh web --host 0.0.0.0` on untrusted networks. + ## Development The bridge plugin and Chrome/Firefox extension are both members of this repository's workspace. Run all commands from the repository root. For the first development installation, run `pnpm install`. diff --git a/README.zh.md b/README.zh.md index e3bed4274..651aace8c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -150,6 +150,21 @@ Chrome 本机使用无需配置;Firefox 需要填写上述本地桥 token。 - 确认桥接已加载:浏览器打开 `http://127.0.0.1:3080/ext/bridge-config`,应返回类似 `{"wsUrl":"ws://127.0.0.1:3080/ext/bridge"}` 的 JSON。如果返回的是网页而不是 JSON,说明当前运行的 dsh 早于桥接注册——重启 dsh 并刷新页面即可,扩展会自动重连。 - 扩展会自动探测 3080/3081/3090/14389 端口。若 dsh 运行在其它端口,或使用 `--host 0.0.0.0` 远程部署,请在面板设置中填写地址与桥接 token。Firefox 始终需要 token。 +**远程 `--host 0.0.0.0` / 局域网部署** + +需要同时满足两点: + +1. **发现地址** — `/ext/bridge-config` 会按请求的 `Host`(以及存在时的 `X-Forwarded-*`)返回 `wsUrl`。访问 `http://192.168.2.185:3080/ext/bridge-config` 会得到 `ws://192.168.2.185:3080/ext/bridge`,而不再是对本机无意义的回环地址。 +2. **扩展 CSP** — 发布用的 manifest 默认只允许回环 `connect-src`。在远程客户端加载前,用额外 origin 重新构建扩展: + +```sh +EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build +# 或 Firefox: +EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build:firefox +``` + +然后加载 `extensions/dsh-browser/dist/`(或 `dist-firefox/`),在面板中填写 `ws://192.168.2.185:3080/ext/bridge`,并粘贴主机上 `~/.dsh/ext-bridge-token` 的桥接 token。不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。 + ## 开发 桥接插件和 Chrome/Firefox 扩展都属于本仓库 workspace;所有命令均在本仓库根目录执行。首次开发安装运行 `pnpm install`。 diff --git a/extensions/dsh-browser/scripts/build.mjs b/extensions/dsh-browser/scripts/build.mjs index a4e5394bf..5de676f44 100644 --- a/extensions/dsh-browser/scripts/build.mjs +++ b/extensions/dsh-browser/scripts/build.mjs @@ -3,6 +3,9 @@ * with --firefox): * background (es|iife) → content (iife) → panel (React). The first target * cleans the output; the later ones append. Pass --watch for dev rebuilds. + * + * Optional: EXT_CONNECT_SRC='ws://192.168.x.x:* http://192.168.x.x:*' appends + * those tokens to the copied manifest's connect-src for remote bridge hosts. */ import { spawn, spawnSync } from 'node:child_process' diff --git a/extensions/dsh-browser/src/connect-src.ts b/extensions/dsh-browser/src/connect-src.ts new file mode 100644 index 000000000..436dc884f --- /dev/null +++ b/extensions/dsh-browser/src/connect-src.ts @@ -0,0 +1,21 @@ +/** + * Manifest CSP helpers used by the extension build and its unit tests. + * Kept free of Vite imports so vitest can load it under jsdom. + */ + +/** + * Append space/comma-separated tokens to a CSP `connect-src` directive. + * Duplicates are skipped so rebuilds with the same EXT_CONNECT_SRC stay stable. + */ +export function appendConnectSrc(csp: string, extras: string): string { + const tokens = extras.split(/[\s,]+/).map((token) => token.trim()).filter((token) => token !== '') + if (tokens.length === 0) return csp + const match = /connect-src\s+([^;]+)/.exec(csp) + if (match === null) return csp + const existing = match[1]!.trim().split(/\s+/).filter((token) => token !== '') + const merged = [...existing] + for (const token of tokens) { + if (!merged.includes(token)) merged.push(token) + } + return csp.replace(/connect-src\s+[^;]+/, `connect-src ${merged.join(' ')}`) +} diff --git a/extensions/dsh-browser/tests/firefox-build.spec.ts b/extensions/dsh-browser/tests/firefox-build.spec.ts index 89d4dee43..d86e534ce 100644 --- a/extensions/dsh-browser/tests/firefox-build.spec.ts +++ b/extensions/dsh-browser/tests/firefox-build.spec.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' +import { appendConnectSrc } from '../src/connect-src.ts' interface ExtensionManifest { version: string @@ -50,4 +51,17 @@ describe('Firefox build contract', () => { 'websiteContent', ]) }) + + it('appends EXT_CONNECT_SRC tokens to connect-src without rewriting the store manifest defaults', async () => { + const chromeManifest = await readJson('../manifest.json') + const base = chromeManifest.content_security_policy.extension_pages + expect(base).toContain('ws://127.0.0.1:*') + expect(base).not.toContain('192.168.2.185') + + const patched = appendConnectSrc(base, 'ws://192.168.2.185:* http://192.168.2.185:*') + expect(patched).toContain('ws://127.0.0.1:*') + expect(patched).toContain('ws://192.168.2.185:*') + expect(patched).toContain('http://192.168.2.185:*') + expect(appendConnectSrc(patched, 'ws://192.168.2.185:*')).toBe(patched) + }) }) diff --git a/extensions/dsh-browser/vite.shared.ts b/extensions/dsh-browser/vite.shared.ts index d02e89b5a..d59ed7ff4 100644 --- a/extensions/dsh-browser/vite.shared.ts +++ b/extensions/dsh-browser/vite.shared.ts @@ -1,7 +1,8 @@ -import { copyFileSync, cpSync, mkdirSync } from 'node:fs' +import { copyFileSync, cpSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vite' +import { appendConnectSrc } from './src/connect-src.ts' /** * Shared build plumbing for the extension's three targets (background ES @@ -19,12 +20,31 @@ export const targetManifest = browserTarget === 'firefox' ? 'manifest.firefox.js export const outDir = resolve(import.meta.dirname, browserTarget === 'firefox' ? 'dist-firefox' : 'dist') +export { appendConnectSrc } + /** Copy manifest, locale catalogs, and icons into the target's outDir. */ export const copyManifest = { name: 'copy-manifest', closeBundle(): void { mkdirSync(outDir, { recursive: true }) - copyFileSync(resolve(import.meta.dirname, targetManifest), resolve(outDir, 'manifest.json')) + const source = resolve(import.meta.dirname, targetManifest) + const dest = resolve(outDir, 'manifest.json') + const extras = process.env.EXT_CONNECT_SRC?.trim() ?? '' + if (extras === '') { + copyFileSync(source, dest) + } else { + const manifest = JSON.parse(readFileSync(source, 'utf8')) as { + content_security_policy?: { extension_pages?: string } + } + const csp = manifest.content_security_policy?.extension_pages + if (typeof csp === 'string') { + manifest.content_security_policy = { + ...manifest.content_security_policy, + extension_pages: appendConnectSrc(csp, extras), + } + } + writeFileSync(dest, `${JSON.stringify(manifest, null, 2)}\n`) + } cpSync(resolve(import.meta.dirname, '_locales'), resolve(outDir, '_locales'), { recursive: true }) cpSync(resolve(import.meta.dirname, 'assets'), resolve(outDir, 'assets'), { recursive: true }) }, diff --git a/packages/browser/bridge-browser/README.md b/packages/browser/bridge-browser/README.md index 62c561814..d1a375199 100644 --- a/packages/browser/bridge-browser/README.md +++ b/packages/browser/bridge-browser/README.md @@ -50,6 +50,7 @@ The installer copies the unpacked extension to `~/.dsh/browser-extension` and op - 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. +- `/ext/bridge-config` derives `wsUrl` from the request `Host` (and `X-Forwarded-Host` / `X-Forwarded-Proto` when present) so LAN clients are not told to dial `127.0.0.1` on their own machine. - 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 67a954a12..3e26ee6c7 100644 --- a/packages/browser/bridge-browser/README.zh.md +++ b/packages/browser/bridge-browser/README.zh.md @@ -50,6 +50,7 @@ npx @deepseek-ai/dsh@0.1.5-rc.2 web - 桥路径在 `/api` 信任栅栏**之外**(栅栏只罩 client-connection 注册的路由),因此自带 bearer token 认证:首帧必须是 `hello`(5 秒内),常量时间比对,失败即断开。 - `/api` 载体钉在回环上的方法(`settings.*`、`credentials.*`、`host.pickDirectory`、`host.openPath`)对非回环来源**即使 token 正确也拒绝**——对 `--host 0.0.0.0` 部署的纵深防御。 - 同一时刻仅一个活动连接,新认证连接顶替旧连接。 +- `/ext/bridge-config` 按请求的 `Host`(以及存在时的 `X-Forwarded-Host` / `X-Forwarded-Proto`)生成 `wsUrl`,避免局域网客户端被要求去连本机 `127.0.0.1`。 - 桥是 confused-deputy 边界而非通用认证层:不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。 - 抽取的页面文字会标记为模型的不可信输入。页面读取遵循扩展的询问/自动/关闭策略;状态变更工具必须经过按 origin 的侧边栏决策,没有侧边栏时失败关闭。同源后续操作可只在当前侧栏会话中临时信任,永久信任仍需显式设置。 diff --git a/packages/browser/bridge-browser/src/bridge-url.ts b/packages/browser/bridge-browser/src/bridge-url.ts index cf957e80a..331be0d0d 100644 --- a/packages/browser/bridge-browser/src/bridge-url.ts +++ b/packages/browser/bridge-browser/src/bridge-url.ts @@ -29,6 +29,71 @@ export function bridgeWsUrlFromLocation( return `${wsProtocol}//${host}${bridgePath}` } +/** Options for reconstructing a bridge URL from an HTTP discovery request. */ +export interface BridgeWsUrlFromHttpHostOptions { + /** Port used when `Host` is missing or omits one (the listening webServer port). */ + fallbackPort: number + /** When true, emit `wss:` (TLS / `X-Forwarded-Proto: https`). */ + secure?: boolean + bridgePath?: string +} + +/** + * Build `ws(s)://…/ext/bridge` from an HTTP `Host` header. + * + * Used by `/ext/bridge-config` so LAN / `--host 0.0.0.0` clients receive a URL + * they can actually dial, instead of a hard-coded loopback address. Missing or + * unusable hosts fall back to `127.0.0.1:` (local discovery). + */ +export function bridgeWsUrlFromHttpHost( + hostHeader: string | undefined, + options: BridgeWsUrlFromHttpHostOptions, +): string { + const bridgePath = options.bridgePath ?? BRIDGE_PATH + const wsProtocol = options.secure === true ? 'wss:' : 'ws:' + const parsed = parseHttpHostHeader(hostHeader) + const hostname = parsed === undefined + ? '127.0.0.1' + : parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname + const port = parsed?.port !== undefined && parsed.port !== '' + ? parsed.port + : String(options.fallbackPort) + const hostLabel = hostname.includes(':') ? `[${hostname}]` : hostname + const host = port === '' ? hostLabel : `${hostLabel}:${port}` + return `${wsProtocol}//${host}${bridgePath}` +} + +/** Split `Host` / `X-Forwarded-Host` into hostname + optional port. */ +export function parseHttpHostHeader( + hostHeader: string | undefined, +): { hostname: string; port: string } | undefined { + const raw = hostHeader?.trim() ?? '' + if (raw === '') return undefined + // First value only when proxies send a comma-separated list. + const host = raw.split(',', 1)[0]!.trim() + if (host === '') return undefined + + if (host.startsWith('[')) { + const end = host.indexOf(']') + if (end <= 1) return undefined + const hostname = host.slice(1, end) + if (hostname === '') return undefined + if (host[end + 1] === ':' && host.length > end + 2) { + return { hostname, port: host.slice(end + 2) } + } + return { hostname, port: '' } + } + + const colon = host.lastIndexOf(':') + if (colon > 0 && host.indexOf(':') === colon) { + const hostname = host.slice(0, colon) + const port = host.slice(colon + 1) + if (hostname === '' || port === '') return undefined + return { hostname, port } + } + return { hostname: host, port: '' } +} + /** * Prefer the discovery endpoint; fall back to reconstructing from `location`. * @param fetchImpl - injectable fetch (defaults to global fetch). diff --git a/packages/browser/bridge-browser/src/index.ts b/packages/browser/bridge-browser/src/index.ts index 1dc3a38e5..da49caa2f 100644 --- a/packages/browser/bridge-browser/src/index.ts +++ b/packages/browser/bridge-browser/src/index.ts @@ -35,6 +35,7 @@ import { DEFAULT_SNAPSHOT_MAX_CHARS, MIN_SNAPSHOT_MAX_CHARS, } from './protocol.ts' +import { bridgeWsUrlFromHttpHost } from './bridge-url.ts' import { withSessionDeferral } from './session-deferral.ts' import { withSessionWorkspace } from './session-workspace.ts' import { purgeSessionFiles, type SessionPurgeDeps } from './session-purge.ts' @@ -231,16 +232,26 @@ function mountBridge( // 异步 disposer:HMR/卸载时先等桥完全关闭(socket/泵/acceptor 静默)再继续。 ctx.effect(() => () => server.close(), 'bridge-browser: bridge server') - // 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. + // Zero-config discovery endpoint: the extension (or a LAN client) fetches + // this to learn the bridge WebSocket URL. The URL carries no secret + // (loopback connections skip the token); non-loopback deployments keep + // requiring the token on the WS itself. Prefer the request Host so + // `--host 0.0.0.0` remotes are not told to dial 127.0.0.1 on their own machine. const configRoute: WebRoute = { kind: 'exact', path: BRIDGE_CONFIG_PATH, - handler: (_req, res) => { + handler: (req, res) => { + const hostHeader = firstHeaderValue(req.headers['x-forwarded-host']) + ?? firstHeaderValue(req.headers.host) + const forwardedProto = firstHeaderValue(req.headers['x-forwarded-proto']) + const secure = forwardedProto === 'https' + || (req.socket as { encrypted?: boolean }).encrypted === true + const wsUrl = bridgeWsUrlFromHttpHost(hostHeader, { + fallbackPort: ctx.webServer.port, + secure, + }) res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ wsUrl: `ws://127.0.0.1:${ctx.webServer.port}${BRIDGE_PATH}` })) + res.end(JSON.stringify({ wsUrl })) }, } ctx.effect(() => ctx.webServer.register(configRoute), 'bridge-browser: /ext/bridge-config route') @@ -279,6 +290,21 @@ type GatewayCandidate = Pick & { readonly wireStream?: TypertGatewayLike['wireStream'] } +/** First value of an HTTP header that may be a string or string[]. */ +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed === '' ? undefined : trimmed + } + if (Array.isArray(value)) { + for (const entry of value) { + const trimmed = entry.trim() + if (trimmed !== '') return trimmed + } + } + return undefined +} + /** Check the minimum supported Gateway contract before mounting the bridge. */ function hasRemoteWireStream(gateway: GatewayCandidate): gateway is TypertGatewayLike { return gateway.wireStream !== undefined diff --git a/packages/browser/bridge-browser/tests/bridge-url.spec.ts b/packages/browser/bridge-browser/tests/bridge-url.spec.ts index 26236e9f5..4686b9531 100644 --- a/packages/browser/bridge-browser/tests/bridge-url.spec.ts +++ b/packages/browser/bridge-browser/tests/bridge-url.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { bridgeWsUrlFromLocation, resolveBridgeWsUrl } from '../src/bridge-url.ts' +import { + bridgeWsUrlFromHttpHost, + bridgeWsUrlFromLocation, + resolveBridgeWsUrl, +} from '../src/bridge-url.ts' describe('bridgeWsUrlFromLocation', () => { it('builds a loopback ws URL and normalizes localhost', () => { @@ -28,6 +32,28 @@ describe('bridgeWsUrlFromLocation', () => { }) }) +describe('bridgeWsUrlFromHttpHost', () => { + it('falls back to loopback when Host is missing', () => { + expect(bridgeWsUrlFromHttpHost(undefined, { fallbackPort: 3080 })) + .toBe('ws://127.0.0.1:3080/ext/bridge') + }) + + it('uses the request Host for LAN / remote discovery', () => { + expect(bridgeWsUrlFromHttpHost('192.168.2.185:3080', { fallbackPort: 3080 })) + .toBe('ws://192.168.2.185:3080/ext/bridge') + }) + + it('normalizes localhost and honors X-Forwarded-style secure flag', () => { + expect(bridgeWsUrlFromHttpHost('localhost:3080', { fallbackPort: 9999, secure: true })) + .toBe('wss://127.0.0.1:3080/ext/bridge') + }) + + it('parses IPv6 Host values', () => { + expect(bridgeWsUrlFromHttpHost('[::1]:3080', { fallbackPort: 3080 })) + .toBe('ws://[::1]:3080/ext/bridge') + }) +}) + describe('resolveBridgeWsUrl', () => { it('prefers /ext/bridge-config when available', async () => { const fetchImpl = vi.fn(async () => new Response( From 5b5d9cee0e4b2df9c7b3890f707280d1b796d38f Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Mon, 21 Sep 2026 01:30:02 +0800 Subject: [PATCH 2/2] fix(browser): let the panel bridge host connect without a CSP rebuild Scheme-wide connect-src already allows any ws/http(s) host, so a remote client can paste ws://:/ext/bridge instead of rebuilding the extension for each LAN address. --- README.md | 14 +++----------- README.zh.md | 14 +++----------- extensions/dsh-browser/manifest.firefox.json | 2 +- extensions/dsh-browser/manifest.json | 2 +- extensions/dsh-browser/scripts/build.mjs | 4 ++-- extensions/dsh-browser/src/panel/strings.ts | 8 ++++---- extensions/dsh-browser/tests/firefox-build.spec.ts | 11 +++++++---- extensions/dsh-browser/tests/updates.spec.ts | 3 ++- 8 files changed, 23 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e21ae9da8..abb25699c 100644 --- a/README.md +++ b/README.md @@ -152,18 +152,10 @@ Local Chrome use requires no configuration; Firefox requires the local bridge to **Remote `--host 0.0.0.0` / LAN deployments** -Two pieces must line up: +1. On the host, run dsh with a reachable bind (for example `dsh web --host 0.0.0.0`). `/ext/bridge-config` answers with a `wsUrl` derived from the request `Host` (and `X-Forwarded-*` when present), so `http://192.168.2.185:3080/ext/bridge-config` returns `ws://192.168.2.185:3080/ext/bridge` instead of loopback. +2. On the client, open the side-panel settings, set the bridge address to `ws://:3080/ext/bridge`, and paste the bridge token from `~/.dsh/ext-bridge-token` on the host. The extension `connect-src` CSP already allows any `ws`/`http(s)` host, so no rebuild is required. -1. **Bridge discovery URL** — `/ext/bridge-config` now answers with a `wsUrl` derived from the request `Host` (and `X-Forwarded-*` when present), so a client that reaches `http://192.168.2.185:3080/ext/bridge-config` receives `ws://192.168.2.185:3080/ext/bridge` instead of a useless loopback address. -2. **Extension CSP** — the published manifests only allow `connect-src` to loopback. Rebuild the extension with extra origins before loading it on the remote client: - -```sh -EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build -# or Firefox: -EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build:firefox -``` - -Then load `extensions/dsh-browser/dist/` (or `dist-firefox/`), set the panel bridge URL to `ws://192.168.2.185:3080/ext/bridge`, and paste the bridge token from `~/.dsh/ext-bridge-token` on the host. Do not expose `dsh web --host 0.0.0.0` on untrusted networks. +Do not expose `dsh web --host 0.0.0.0` on untrusted networks. Optional: `EXT_CONNECT_SRC` can still append extra origins at build time if you need a tighter or custom CSP. ## Development diff --git a/README.zh.md b/README.zh.md index 651aace8c..ae6f57a52 100644 --- a/README.zh.md +++ b/README.zh.md @@ -152,18 +152,10 @@ Chrome 本机使用无需配置;Firefox 需要填写上述本地桥 token。 **远程 `--host 0.0.0.0` / 局域网部署** -需要同时满足两点: +1. 主机用可被访问的绑定启动 dsh(例如 `dsh web --host 0.0.0.0`)。`/ext/bridge-config` 会按请求的 `Host`(以及存在时的 `X-Forwarded-*`)返回 `wsUrl`。访问 `http://192.168.2.185:3080/ext/bridge-config` 会得到 `ws://192.168.2.185:3080/ext/bridge`,而不再是回环地址。 +2. 客户端打开侧栏设置,把桥地址写成 `ws://<主机IP>:3080/ext/bridge`,并粘贴主机上 `~/.dsh/ext-bridge-token` 的桥接 token。扩展默认 CSP 已允许任意 `ws`/`http(s)` 主机,无需为局域网 IP 重新打包。 -1. **发现地址** — `/ext/bridge-config` 会按请求的 `Host`(以及存在时的 `X-Forwarded-*`)返回 `wsUrl`。访问 `http://192.168.2.185:3080/ext/bridge-config` 会得到 `ws://192.168.2.185:3080/ext/bridge`,而不再是对本机无意义的回环地址。 -2. **扩展 CSP** — 发布用的 manifest 默认只允许回环 `connect-src`。在远程客户端加载前,用额外 origin 重新构建扩展: - -```sh -EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build -# 或 Firefox: -EXT_CONNECT_SRC='ws://192.168.2.185:* http://192.168.2.185:*' pnpm --filter dsh-browser-extension run build:firefox -``` - -然后加载 `extensions/dsh-browser/dist/`(或 `dist-firefox/`),在面板中填写 `ws://192.168.2.185:3080/ext/bridge`,并粘贴主机上 `~/.dsh/ext-bridge-token` 的桥接 token。不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。 +不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。可选:构建时仍可用 `EXT_CONNECT_SRC` 追加额外 origin,以便收紧或定制 CSP。 ## 开发 diff --git a/extensions/dsh-browser/manifest.firefox.json b/extensions/dsh-browser/manifest.firefox.json index 8d6dda156..5e72bfa66 100644 --- a/extensions/dsh-browser/manifest.firefox.json +++ b/extensions/dsh-browser/manifest.firefox.json @@ -68,7 +68,7 @@ } ], "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* http://127.0.0.1:* https://raw.githubusercontent.com" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws: wss: http: https:" }, "icons": { "16": "assets/icons/icon16.png", diff --git a/extensions/dsh-browser/manifest.json b/extensions/dsh-browser/manifest.json index d1311c27b..54440e627 100644 --- a/extensions/dsh-browser/manifest.json +++ b/extensions/dsh-browser/manifest.json @@ -48,7 +48,7 @@ } ], "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* http://127.0.0.1:* https://raw.githubusercontent.com" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws: wss: http: https:" }, "icons": { "16": "assets/icons/icon16.png", diff --git a/extensions/dsh-browser/scripts/build.mjs b/extensions/dsh-browser/scripts/build.mjs index 5de676f44..980b44881 100644 --- a/extensions/dsh-browser/scripts/build.mjs +++ b/extensions/dsh-browser/scripts/build.mjs @@ -4,8 +4,8 @@ * background (es|iife) → content (iife) → panel (React). The first target * cleans the output; the later ones append. Pass --watch for dev rebuilds. * - * Optional: EXT_CONNECT_SRC='ws://192.168.x.x:* http://192.168.x.x:*' appends - * those tokens to the copied manifest's connect-src for remote bridge hosts. + * Optional: EXT_CONNECT_SRC='…' appends extra connect-src tokens at copy time. + * Default manifests already allow any ws/http(s) host for LAN bridge URLs. */ import { spawn, spawnSync } from 'node:child_process' diff --git a/extensions/dsh-browser/src/panel/strings.ts b/extensions/dsh-browser/src/panel/strings.ts index d255d342a..ada7ab252 100644 --- a/extensions/dsh-browser/src/panel/strings.ts +++ b/extensions/dsh-browser/src/panel/strings.ts @@ -293,8 +293,8 @@ const EN: PanelCopy = { eyebrow: 'Browser assistant', title: 'Settings', bridgeAddress: 'Bridge address', - bridgeHelp: 'Leave blank to detect a local service automatically', - bridgePlaceholder: 'Auto-detect 3080 / 3081 / 3090 / 14389 / 43189', + bridgeHelp: 'Leave blank to detect a local service, or set ws://:3080/ext/bridge for LAN', + bridgePlaceholder: 'ws://192.168.x.x:3080/ext/bridge', tokenHelp: 'Required by Firefox and remote deployments', tokenPlaceholder: 'Required for Firefox / remote deployments', pageSharing: 'Page content sharing', @@ -523,8 +523,8 @@ const ZH: PanelCopy = { eyebrow: '浏览器助手', title: '设置', bridgeAddress: '桥地址', - bridgeHelp: '留空时自动检测本机服务', - bridgePlaceholder: '自动检测 3080 / 3081 / 3090 / 14389 / 43189', + bridgeHelp: '留空自动检测本机;局域网填写 ws://<主机IP>:3080/ext/bridge', + bridgePlaceholder: 'ws://192.168.x.x:3080/ext/bridge', tokenHelp: 'Firefox 和远程部署需要填写', tokenPlaceholder: 'Firefox / 远程部署时填写', pageSharing: '页面内容共享', diff --git a/extensions/dsh-browser/tests/firefox-build.spec.ts b/extensions/dsh-browser/tests/firefox-build.spec.ts index d86e534ce..24895013a 100644 --- a/extensions/dsh-browser/tests/firefox-build.spec.ts +++ b/extensions/dsh-browser/tests/firefox-build.spec.ts @@ -32,7 +32,7 @@ describe('Firefox build contract', () => { expect(firefoxManifest.version).toBe(packageManifest.version) expect(firefoxManifest.version).toBe(chromeManifest.version) expect(firefoxManifest.permissions).toContain('notifications') - expect(firefoxManifest.content_security_policy.extension_pages).toContain('https://raw.githubusercontent.com') + expect(firefoxManifest.content_security_policy.extension_pages).toMatch(/\bhttps:/) }) it('uses a Firefox event page, sidebar, and AMO data-transmission declaration', async () => { @@ -52,14 +52,17 @@ describe('Firefox build contract', () => { ]) }) - it('appends EXT_CONNECT_SRC tokens to connect-src without rewriting the store manifest defaults', async () => { + it('allows any ws/http(s) host via scheme sources so panel settings can target a LAN bridge', async () => { const chromeManifest = await readJson('../manifest.json') const base = chromeManifest.content_security_policy.extension_pages - expect(base).toContain('ws://127.0.0.1:*') + expect(base).toContain('ws:') + expect(base).toContain('wss:') + expect(base).toContain('http:') + expect(base).toContain('https:') expect(base).not.toContain('192.168.2.185') const patched = appendConnectSrc(base, 'ws://192.168.2.185:* http://192.168.2.185:*') - expect(patched).toContain('ws://127.0.0.1:*') + expect(patched).toContain('ws:') expect(patched).toContain('ws://192.168.2.185:*') expect(patched).toContain('http://192.168.2.185:*') expect(appendConnectSrc(patched, 'ws://192.168.2.185:*')).toBe(patched) diff --git a/extensions/dsh-browser/tests/updates.spec.ts b/extensions/dsh-browser/tests/updates.spec.ts index fae4bd54a..a37553c28 100644 --- a/extensions/dsh-browser/tests/updates.spec.ts +++ b/extensions/dsh-browser/tests/updates.spec.ts @@ -127,7 +127,8 @@ describe('extension update checks', () => { const installer = readFileSync(`${extensionRoot}/../../scripts/install.sh`, 'utf8') expect(packageManifest.version).toBe(chromeManifest.version) - expect(chromeManifest.content_security_policy.extension_pages).toContain(new URL(UPDATE_MANIFEST_URL).origin) + // Update checks fetch UPDATE_MANIFEST_URL over https; scheme source covers any host. + expect(chromeManifest.content_security_policy.extension_pages).toMatch(/\bhttps:/) expect(installer).toContain('INSTALL_MODE="managed"') expect(installer).toContain('INSTALL_MODE="checkout"') expect(installer).toContain('$DIST_DIR/install-info.json')