From ea250c9e879965939e6057a1ed116506f61340ab Mon Sep 17 00:00:00 2001 From: Rookie_Zoe <11600873+RookieZoe@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:58:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20/sidebar=20=E4=BF=A1=E4=BB=BB=E6=A0=85?= =?UTF-8?q?=E6=A0=8F=E6=94=B9=E7=94=A8=20webRuntime.trustedHosts=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9C=E7=A8=8B=E8=AE=BF=E9=97=AE=E5=85=A8?= =?UTF-8?q?=E9=83=A8=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:trustedHostsOf 按 entry.options.name === 'connection' 查找 connection 行,但该行 name 是模块标识符(@deepseek-ai/dsh-client-connection,id 才是 connection),查找永远落空 → trustedHosts 为空 → 栅栏只放行回环 Host → 通过 域名或局域网 IP 访问时 /sidebar/bundle chunk 加载失败、资源管理器/代码管理器 全部 403 forbidden(/api 不受影响,它读的是 apply 时已求值的配置)。 修复(按 review 建议):host half 直接注入 webRuntime 服务,栅栏每请求实时 读取 ctx.webRuntime.trustedHosts —— 与 /api 网关同一信任列表(启动时采样的 LAN IP 字面量 + --trusted-host 权威),不再依赖 Loader entry / fiber / !!js 节点求值;自定义信任域名由 dsh web --trusted-host 提供。 新增 tests/trusted-hosts.spec.ts 回归:域名/局域网放行、空列表仅回环、 跨站标记拒绝、每请求实时读取;plugin-shape 断言同步更新。 --- src/context-types.ts | 14 ++- src/index.ts | 29 ++---- tests/plugin-shape.spec.ts | 2 +- tests/smoke.spec.ts | 10 +- tests/trusted-hosts.spec.ts | 181 ++++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 tests/trusted-hosts.spec.ts diff --git a/src/context-types.ts b/src/context-types.ts index a637585..a6d60f4 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -12,7 +12,7 @@ * - conversation: client side ui-conversation's IConversation (composer * draft), read lazily through `ctx.get` — cross-plugin service reads need * an inject declaration, so the direct property is never typed here - * - loader: @cordisjs/plugin-loader (entry options) + * - webRuntime: @deepseek-ai/dsh-web-app (bind-derived trusted hosts) * - slots: the client runtime SlotRegistry * - effect: the DSH-vendored cordis lifecycle helper * Drift from upstream is contained to this file. @@ -102,6 +102,16 @@ export interface SidebarLoader { entries(): Iterable } +/** + * The web runtime service face (mirror of @deepseek-ai/dsh-web-app's + * WebRuntimeValues): the bind-derived trust list the /api gateway's fence + * accepts — LAN IP literals sampled when the server binds all interfaces, + * plus explicit `--trusted-host` authorities. + */ +export interface SidebarWebRuntime { + trustedHosts: readonly string[] +} + /** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */ export interface SidebarSlotRegisterOptions { name: string @@ -416,7 +426,7 @@ declare module 'cordis' { webServer: SidebarWebServer sessions: SidebarSessionStore & SidebarSessionsService connection: SidebarConnectionHandle - loader: SidebarLoader + webRuntime: SidebarWebRuntime slots: SidebarSlotsService workspaces: SidebarWorkspacesService settings: SidebarSettingsService diff --git a/src/index.ts b/src/index.ts index bdc0c7a..ca7f557 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,9 @@ * preview route, the /sidebar/bundle lazy-chunk route (client code splits), * and the terminal WebSocket upgrade. Every route passes the same * browser-trust fence as the /api gateway — Host-header loopback or the - * connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP - * literals per boot) — with the trustedHosts read live from the connection - * loader row so the fence never drifts from the deployment's. + * web runtime's `trustedHosts` (LAN IP literals derived per boot plus + * `--trusted-host` authorities) — read live per request so the fence never + * drifts from the deployment's. * * All operations are conversation-scoped: requests carry a sessionId, the * session's authoritative cwd comes from the session store, and terminal @@ -59,8 +59,8 @@ export type { /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' -/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */ -export const inject = ['webServer', 'sessions', 'loader', 'tools'] +/** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */ +export const inject = ['webServer', 'sessions', 'webRuntime', 'tools'] /** Content types for the media route, by extension. */ const MEDIA_TYPES: Record = { @@ -83,17 +83,6 @@ export function mediaTypeForPath(path: string): string { return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream' } -/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */ -function trustedHostsOf(ctx: Context): string[] { - for (const entry of ctx.loader.entries()) { - if (entry.options.name === 'connection') { - const config = entry.options.config as { trustedHosts?: string[] } | undefined - return config?.trustedHosts ?? [] - } - } - return [] -} - /** * Resolve a session's authoritative working directory. The attached session * header wins; while the session is still hydrating from persistence (the @@ -426,7 +415,7 @@ function buildApi( /** * Plugin body: mount the fenced routes and the pty lifecycle. - * @param ctx - host plugin context (webServer, sessions, loader). + * @param ctx - host plugin context (webServer, sessions, webRuntime). * @param config - deployment-provided limits; the Loader validates against * {@link Config} and fills defaults, direct callers get them from * {@link resolveSidebarConfig}. @@ -436,8 +425,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // restore it before any terminal can spawn (idempotent). ensureSpawnHelper() const resolved = resolveSidebarConfig(config) - const trustedHosts = trustedHostsOf(ctx) - const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, trustedHosts) + // The web runtime's bind-derived trust list (LAN literals plus + // --trusted-host authorities) — the same list the /api gateway fence + // accepts. Read per request so config reloads apply without a restart. + const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts) const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession) // The agent-owned terminal registry: parallel to the UI-tab ptyManager, // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 84a6b71..1de4bcf 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => { const unwrapped = loader.unwrapExports(sidebar) as Record expect(unwrapped).toBe(sidebar) expect(unwrapped.name).toBe('dsh-better-sidebar') - expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'loader', 'tools']) + expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'webRuntime', 'tools']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 8a71fdd..ef13e3a 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -16,7 +16,7 @@ import { defaultShell, PtyManager } from '../src/pty-manager.ts' import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' interface FakeContext { - loader: { entries: () => never[] } + webRuntime: { trustedHosts: readonly string[] } webServer: { register: (route: SidebarWebRoute) => () => void registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void @@ -42,7 +42,7 @@ describe('host plugin smoke', () => { const upgrades: SidebarWebUpgradeRoute[] = [] const effects: Array<() => void | (() => void)> = [] const ctx: FakeContext = { - loader: { entries: () => [] }, + webRuntime: { trustedHosts: [] }, webServer: { register: (route) => { routes.push(route); return () => {} }, registerUpgrade: (route) => { upgrades.push(route); return () => {} }, @@ -308,7 +308,7 @@ describe('session cwd resolution over the API route', () => { const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => { const routes: SidebarWebRoute[] = [] const ctx = { - loader: { entries: () => [] }, + webRuntime: { trustedHosts: [] }, webServer: { register: (route: SidebarWebRoute) => { routes.push(route); return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, @@ -458,7 +458,7 @@ describe('side card settings routes', () => { const mountWithSettings = (settings?: unknown): SidebarWebRoute => { const routes: SidebarWebRoute[] = [] const ctx = { - loader: { entries: () => [] }, + webRuntime: { trustedHosts: [] }, webServer: { register: (route: SidebarWebRoute) => { routes.push(route); return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, @@ -648,7 +648,7 @@ describe('agent terminal tool gating', () => { async update() {}, } const ctx = { - loader: { entries: () => [] }, + webRuntime: { trustedHosts: [] }, webServer: { register: (route: SidebarWebRoute) => { void route; return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, diff --git a/tests/trusted-hosts.spec.ts b/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000..af78f57 --- /dev/null +++ b/tests/trusted-hosts.spec.ts @@ -0,0 +1,181 @@ +/** + * Remote-access trust regression tests: the /sidebar fence must accept a + * reverse-proxy domain or LAN IP listed in the DSH web runtime's + * `webRuntime.trustedHosts` — the same trust list the /api gateway accepts + * (LAN IP literals sampled at bind plus `--trusted-host` authorities). + * + * Regression: the fence used to read the connection row's trustedHosts + * through the Loader (`entry.options.name === 'connection'`, which never + * matched the row's module-specifier name), so trustedHosts stayed empty and + * every remote /sidebar request was 403 "forbidden". + */ +import { describe, expect, it } from 'vitest' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { apply } from '../src/index.ts' +import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' + +interface FakeRes { + status: number + headers: Record + body: string + writeHead(status: number, headers?: Record): void + end(body?: string | Buffer): void +} + +function fakeRes(): FakeRes { + return { + status: 0, + headers: {}, + body: '', + writeHead(status, headers = {}) { + this.status = status + this.headers = headers + }, + end(body) { + if (body !== undefined) this.body = body.toString() + }, + } as FakeRes +} + +function req( + method: string, + url: string, + headers: Record, + body = '{}', +): IncomingMessage { + const chunks = [Buffer.from(body)] + return { + method, + url, + headers, + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) yield chunk + }, + } as unknown as IncomingMessage +} + +/** Mount apply() against a fake context with a replaceable webRuntime trust list. */ +function mount(initialTrustedHosts: readonly string[] = []): { + api: (r: IncomingMessage, s: ServerResponse) => Promise + setTrustedHosts: (hosts: readonly string[]) => void + cleanup: () => void +} { + const runtime = { trustedHosts: [...initialTrustedHosts] } + const routes: SidebarWebRoute[] = [] + const upgrades: SidebarWebUpgradeRoute[] = [] + const effects: Array<() => void | (() => void)> = [] + const ctx = { + webRuntime: runtime, + webServer: { + register: (route: SidebarWebRoute) => { routes.push(route); return () => {} }, + registerUpgrade: (route: SidebarWebUpgradeRoute) => { upgrades.push(route); return () => {} }, + }, + sessions: { get: () => undefined }, + tools: { register: () => () => {} }, + effect: (fn: () => void | (() => void)) => { + const cleanup = fn() + if (typeof cleanup === 'function') effects.push(cleanup) + }, + inject: () => () => {}, + get: () => undefined, + } + apply(ctx as never) + const api = routes.find(route => route.path === '/sidebar/api')?.handler + if (api === undefined) throw new Error('test setup: /sidebar/api route not registered') + return { + api: api as (r: IncomingMessage, s: ServerResponse) => Promise, + setTrustedHosts: (hosts) => { runtime.trustedHosts = [...hosts] }, + cleanup: () => { for (const cleanup of effects) cleanup() }, + } +} + +/** A remote-domain request with the browser markers a same-origin fetch sends. */ +function remoteDomainRequest(): IncomingMessage { + return req('POST', '/sidebar/api/session.cwd', { + host: 'example.com', + 'sec-fetch-site': 'same-origin', + origin: 'https://example.com', + }) +} + +/** A LAN-IP request with the browser markers a same-origin fetch sends. */ +function lanRequest(): IncomingMessage { + return req('POST', '/sidebar/api/session.cwd', { + host: '192.0.2.1:3080', + 'sec-fetch-site': 'same-origin', + origin: 'http://192.0.2.1:3080', + }) +} + +describe('remote-access trust (webRuntime.trustedHosts)', () => { + it('accepts a reverse-proxy domain configured in webRuntime.trustedHosts', async () => { + const { api, cleanup } = mount(['example.com']) + try { + const res = fakeRes() + await api(remoteDomainRequest(), res as unknown as ServerResponse) + expect(res.status).not.toBe(403) + } finally { + cleanup() + } + }) + + it('accepts LAN IP literals from webRuntime.trustedHosts', async () => { + const { api, cleanup } = mount(['192.0.2.1']) + try { + const res = fakeRes() + await api(lanRequest(), res as unknown as ServerResponse) + expect(res.status).not.toBe(403) + } finally { + cleanup() + } + }) + + it('stays loopback-only when webRuntime.trustedHosts is empty', async () => { + const { api, cleanup } = mount([]) + try { + const remote = fakeRes() + await api(remoteDomainRequest(), remote as unknown as ServerResponse) + expect(remote.status).toBe(403) + const loopback = fakeRes() + await api(req('POST', '/sidebar/api/session.cwd', { + host: '127.0.0.1:3080', + 'sec-fetch-site': 'same-origin', + }), loopback as unknown as ServerResponse) + expect(loopback.status).not.toBe(403) + } finally { + cleanup() + } + }) + + it('rejects cross-site browser markers even for a trusted host', async () => { + const { api, cleanup } = mount(['example.com']) + try { + const res = fakeRes() + await api(req('POST', '/sidebar/api/session.cwd', { + host: 'example.com', + 'sec-fetch-site': 'cross-site', + origin: 'https://evil.example.net', + }), res as unknown as ServerResponse) + expect(res.status).toBe(403) + } finally { + cleanup() + } + }) + + it('reads webRuntime.trustedHosts per request, not once at apply', async () => { + const { api, setTrustedHosts, cleanup } = mount([]) + try { + const before = fakeRes() + await api(remoteDomainRequest(), before as unknown as ServerResponse) + expect(before.status).toBe(403) + // The trust list gains an authority after mount (e.g. config reload): + // the already-mounted fence must pick it up without a plugin restart. + setTrustedHosts(['example.com']) + const after = fakeRes() + await api(remoteDomainRequest(), after as unknown as ServerResponse) + expect(after.status).not.toBe(403) + } finally { + cleanup() + } + }) +})