fix: /sidebar 信任栅栏改用 webRuntime.trustedHosts,修复远程访问全部 403
根因: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 断言同步更新。
This commit is contained in:
+12
-2
@@ -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<SidebarLoaderEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+10
-19
@@ -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<string, string> = {
|
||||
@@ -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}`,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
|
||||
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')
|
||||
})
|
||||
|
||||
+5
-5
@@ -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 () => {} },
|
||||
|
||||
@@ -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<string, string>
|
||||
body: string
|
||||
writeHead(status: number, headers?: Record<string, string>): 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<string, string>,
|
||||
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<void>
|
||||
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<void>,
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user