diff --git a/extensions/dsh-browser/src/background/index.ts b/extensions/dsh-browser/src/background/index.ts index a145a662..14bae132 100644 --- a/extensions/dsh-browser/src/background/index.ts +++ b/extensions/dsh-browser/src/background/index.ts @@ -165,6 +165,25 @@ function isLocalBridgeAddress(url: string): boolean { } } +/** + * 把回环别名统一成扩展 CSP 允许的 `127.0.0.1`。 + * + * `isLocalBridgeAddress` 接受 `localhost` / `::1`,但扩展 `connect-src` 只列了 + * `127.0.0.1`:用别名探测时 fetch 会先被扩展自己的 CSP 拦掉,预检因此失败, + * 自动探测又把该端口当作「已试过」排除;于是一个明明在该端口监听的服务反而 + * 连不上(或者误连到另一个本机实例)。只改连接地址,不改用户保存的设置。 + */ +function canonicalLoopbackUrl(raw: string): string { + if (!isLocalBridgeAddress(raw)) return raw + try { + const parsed = new URL(raw) + parsed.hostname = '127.0.0.1' + return parsed.toString() + } catch { + return raw + } +} + /** 记下成功的地址,供下次面板打开时优先尝试。 */ function rememberBridgeUrl(url: string): void { if (lastBridgeUrl === url) return @@ -1658,6 +1677,8 @@ async function startBridge(): Promise { const lease = (): boolean => revision === bridgeStartRevision && panelPorts.size > 0 let url = settings.bridgeUrl if (url !== '') { + // The extension CSP only whitelists 127.0.0.1; probe and connect through it. + url = canonicalLoopbackUrl(url) if (isLocalBridgeAddress(url) && !await probeBridge(url)) { if (!lease()) return url = await discoverBridgeUrl(lease, url) ?? '' diff --git a/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts b/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts index 310bd239..646aa4d7 100644 --- a/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts +++ b/extensions/dsh-browser/tests/background-bridge-lifecycle.spec.ts @@ -1033,6 +1033,34 @@ describe('background bridge lifecycle', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it.each([ + ['localhost', 'ws://localhost:3080/ext/bridge'], + ['IPv6 loopback', 'ws://[::1]:3080/ext/bridge'], + ])('reaches a manual %s address through the CSP-allowed loopback origin', async (_alias, bridgeUrl) => { + const chromeMock = mockChrome({ + localGet: async () => ({ dshSettings: { bridgeUrl } }), + }) + // The extension's connect-src lists only 127.0.0.1, so the browser blocks + // every other loopback alias before the request reaches the host. The + // requested bridge is the only instance listening on this machine. + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const target = new URL(String(input)) + if (target.hostname !== '127.0.0.1') throw new TypeError('Failed to fetch: blocked by connect-src') + if (target.port !== '3080') return new Response(null, { status: 503 }) + return new Response(JSON.stringify({ wsUrl: `ws://127.0.0.1:${target.port}/ext/bridge` }), { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('WebSocket', FakeWebSocket) + await import('../src/background/index.ts') + + const panel = panelPort() + chromeMock.onConnect.emit(panel.port) + await vi.waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1) }) + + expect(FakeWebSocket.instances[0]!.url).toBe('ws://127.0.0.1:3080/ext/bridge') + expect(String(fetchMock.mock.calls[0]![0])).toBe('http://127.0.0.1:3080/ext/bridge-config') + }) + it('does not probe again while the lease remembers a confirmed miss', async () => { const chromeMock = mockChrome({ // DSH stayed stopped across a service-worker restart: the latch survives.