From 01eea07bab1b73dac380919c8dafa94fa5adc9ba Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:40:02 +0800 Subject: [PATCH 1/3] fix(connection): keep LAN serving working under the /api browser-trust fence Markerless requests pass on any Host (a non-browser sender is the principal and forges headers anyway); browser Host matching gains port-less entries and WHATWG normalization; dsh derives LAN IP-literal authorities for an all-interfaces bind and web grows --trusted-host for named ones. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 4 +- ...026-07-28-api-browser-trust-boundary.zh.md | 4 +- apps/cli/README.i18n.yaml | 6 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 41 +++++++++++++ apps/cli/src/args.ts | 5 ++ apps/cli/src/bin.ts | 2 +- apps/cli/src/web.ts | 19 +++---- apps/cli/tests/args.spec.ts | 3 + apps/cli/tests/trusted-hosts.spec.ts | 40 +++++++++++++ docs/config-catalog.md | 9 +-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 57 +++++++++++++------ packages/client/connection/src/index.ts | 9 +-- .../tests/api-request-trust.spec.ts | 45 ++++++++++----- .../client/connection/tests/node-half.spec.ts | 5 ++ 20 files changed, 199 insertions(+), 66 deletions(-) create mode 100644 apps/cli/tests/trusted-hosts.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 11973b755f..68473ee893 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: c620f1a65e3890bbd2580415e55b25436fefe36e -2026-07-28-api-browser-trust-boundary.zh.md: 0452eff1017b2f70a00e67c5cfce8dba3a840539 +2026-07-28-api-browser-trust-boundary.md: 45a332fcfe59fb930a85cfc595dd02c5fe12a5d7 +2026-07-28-api-browser-trust-boundary.zh.md: 731d6c81f71a2f50b716e52e278f2c53ad62a04b diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index c620f1a65e..45a332fcfe 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. -- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: `Host` must be loopback or an exact `host[:port]` from the plugin's `trustedHosts` config (rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. Requests without browser markers pass — a non-browser client is the principal itself, not a deputy. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. `host.pickDirectory` loses its bespoke guard and rides the same fence. Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. @@ -26,6 +26,6 @@ Two boundaries stay deliberately out of scope: reachability is the webserver bin ## Consequences - Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. -- Non-loopback deployments must declare their serving authorities in `trustedHosts` or browsers are refused; plain curl-shape automation is unaffected either way. +- Non-loopback deployments must have their serving authorities trusted or browsers are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Plain curl-shape automation is unaffected everywhere. - Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). - The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 0452eff101..731d6c81f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:`Host` 必须是回环地址,或与插件 `trustedHosts` 配置中的某个 `host[:port]` 精确匹配(rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不带浏览器标头的请求放行——非浏览器客户端是委托人本人,不是代理人。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 @@ -26,6 +26,6 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 非回环部署必须在 `trustedHosts` 中声明服务权威,否则浏览器会被拒绝;curl 形态的自动化不受影响。 +- 非回环部署的服务权威必须获得信任,否则浏览器会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。curl 形态的自动化在任何地方都不受影响。 - 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 - 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..3322014ad5 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: f5e52382fb86ecd6b96b84b90b310514285f1904 +README.zh.md: b2089c67d751a25c6443a5e15b53266c728e5156 diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..f5e52382fb 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..b2089c67d7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -4,7 +4,7 @@ `dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 344c66e2b3..1002b45660 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { networkInterfaces } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from 'cordis' @@ -25,6 +26,38 @@ import type {} from '@deepseek-ai/dsh-host-webserver' const PROFILE_DIR = '.dsh-tmp-profile' const PROFILE_FILE = 'config.json' +/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */ +export const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Non-internal IPv4 interface addresses of this machine — the IP-literal + * authorities an all-interfaces bind is reachable by on the LAN. + * @returns the addresses in interface order (possibly empty). + */ +export function lanIPv4Addresses(): string[] { + return Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) +} + +/** + * Authorities the /api browser-trust fence must accept for one invocation: + * the machine's LAN IP literals when the effective bind is all-interfaces + * (advertised by the printed LAN URL, so they must not answer 403), followed + * by the explicit extras. Derived entries are port-less IP literals — DNS + * rebinding needs an attacker-controlled name, so an IP-literal Host is safe + * on any port, and the bound port may be OS-assigned, unknowable pre-boot. + * @param bindHost - the effective webserver bind host (CLI flag, else the yml default). + * @param extra - `--trusted-host` values, in argv order. + * @returns the connection row's `trustedHosts` value (possibly empty). + */ +export function resolveTrustedHosts(bindHost: string | undefined, extra: readonly string[]): string[] { + return [ + ...bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [], + ...extra, + ] +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -79,6 +112,8 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string + /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ + trustedHosts?: string[] } /** @@ -152,6 +187,12 @@ export class AppCLIEntry { if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) + // Source 2b: authorities for the /api browser-trust fence (rationale on + // resolveTrustedHosts). + const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host + const trustedHosts = resolveTrustedHosts(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) + // Source 3: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 9fd0f4d9bf..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,6 +40,8 @@ interface WebInvocation { port?: number dev: boolean workspaceRoot?: string + /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ + trustedHosts?: string[] } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ @@ -51,6 +53,7 @@ interface WebOptions { port?: string dev?: boolean workspaceRoot?: string + trustedHost?: string[] } /** @@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation { ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } } @@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { // Commander parses the parent (default-surface) options on either side of // the subcommand into `program.opts()`. `web` shares none of them, so a diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index f9e1eefc9b..88dbece55a 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) break } case 'headless': { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 31282c8f5f..3d7fc29ab4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -6,17 +6,14 @@ * gates them at boot. */ -import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' -import { AppCLIEntry } from './app-cli-entry.ts' +import { ALL_INTERFACES_HOST, AppCLIEntry, lanIPv4Addresses } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Display-only mirrors of the webserver schema's allowed hosts: the loopback -// address the local URL always prints, and the all-interfaces value that gates -// LAN-address discovery. Not a source of truth — the schema is. +// Display-only mirror of the webserver schema's loopback host: the address the +// local URL always prints. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' -const ALL_INTERFACES_HOST = '0.0.0.0' /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed @@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0' * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. + * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. */ export async function runWeb( host: string | undefined, port: number | undefined, dev: boolean, workspaceRoot: string | undefined, + trustedHosts: string[] | undefined, ): Promise { const entry = new AppCLIEntry({ configPath: CONFIG_PATH, @@ -38,6 +37,7 @@ export async function runWeb( ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, + ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() @@ -48,12 +48,9 @@ export async function runWeb( void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = host === ALL_INTERFACES_HOST - ? Object.values(networkInterfaces()).flat() - .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - : undefined + const lanCandidate = host === ALL_INTERFACES_HOST ? lanIPv4Addresses()[0] : undefined const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` - console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) + console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`) process.on('SIGTERM', () => { shutdown(0) }) process.on('SIGINT', () => { shutdown(130) }) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 052e96e9a0..45830eee30 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -35,6 +35,9 @@ describe('parseDshArgs', () => { // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // --trusted-host is variadic and repeatable; authorities pass through unvalidated. + expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) + .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) it('exits nonzero instead of silently starting fresh or dropping inputs', () => { diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000000..1ed0f602b8 --- /dev/null +++ b/apps/cli/tests/trusted-hosts.spec.ts @@ -0,0 +1,40 @@ +/** LAN-authority derivation for the /api browser-trust fence (`resolveTrustedHosts`). */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { lanIPv4Addresses, resolveTrustedHosts } from '../src/app-cli-entry.ts' + +vi.mock('node:os', () => ({ + networkInterfaces: () => ({ + lo0: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' }, + ], + en0: [ + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv4', internal: false, address: '192.168.1.5' }, + ], + en1: [ + { family: 'IPv4', internal: false, address: '10.0.0.7' }, + ], + utun0: undefined, + }), +})) + +afterEach(() => { vi.restoreAllMocks() }) + +describe('lanIPv4Addresses', () => { + it('returns only non-internal IPv4 addresses, in interface order', () => { + expect(lanIPv4Addresses()).toEqual(['192.168.1.5', '10.0.0.7']) + }) +}) + +describe('resolveTrustedHosts', () => { + it('derives port-less LAN IP literals for an all-interfaces bind, ahead of the extras', () => { + expect(resolveTrustedHosts('0.0.0.0', ['harness.internal:3080'])) + .toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) + + it('derives nothing for a loopback or unresolved bind — extras alone stand', () => { + expect(resolveTrustedHosts('127.0.0.1', [])).toEqual([]) + expect(resolveTrustedHosts(undefined, ['lab.internal'])).toEqual(['lab.internal']) + }) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d30aff1f02..6155c418e0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -278,10 +278,11 @@ Requires: `httpServer` · `apiProxy` /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { /** - * Exact `host[:port]` authorities this deployment serves beyond loopback. - * The /api trust fence refuses any request whose Host is neither loopback - * nor listed here, so a non-loopback (`0.0.0.0`) deployment must declare - * the names it is reached by. + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * browser request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). */ trustedHosts?: string[] } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 5c5a825a27..8310adac83 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: a301b85d707d17d1e8159655b540556eee5c9d83 -README.zh.md: 88d7aa806167033308a9913053f621ecea07c3d8 +README.md: 94b9b3c8d4bde30cedf56e31d83efe9f5f1dd87c +README.zh.md: 844a2ef030378c32994f7459792db98c779f24b7 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index a301b85d70..94b9b3c8d4 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`): the `Host` header must be a loopback authority or an exact `host[:port]` entry from the plugin's `trustedHosts` config (DNS-rebinding defense), an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Requests without browser markers (curl, tests, native clients) pass — without a browser there is no confused deputy. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment must therefore list the authorities it is reached by in `trustedHosts`; the fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 88d7aa8061..844a2ef030 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`):`Host` 头必须是回环地址权威,或与插件 `trustedHosts` 配置中的某个 `host[:port]` 精确匹配(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不带浏览器标头的请求(curl、测试、原生客户端)直接放行——没有浏览器就不存在"混淆代理人"。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署必须在 `trustedHosts` 中列出自己被访问时使用的权威;这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 ## 无密钥 fixture diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 37819b9fb9..11dc6d7621 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -3,10 +3,11 @@ * paths a browser opens against a local HTTP API — DNS rebinding (Host names * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page — without blocking non-browser clients - * (no browser markers → no deputy to confuse) or legitimately remote browsers - * (their authority is declared via `trustedHosts`). Network reachability and - * authentication stay out of scope: binding policy belongs to the webserver - * config, and this fence is not an auth layer. + * (no browser markers → no deputy to confuse, and a native client forges Host + * freely anyway) or legitimately remote browsers (their authority is declared + * via `trustedHosts`, or derived by the composing app for IP-literal LAN + * serving). Network reachability and authentication stay out of scope: binding + * policy belongs to the webserver config, and this fence is not an auth layer. */ import type { IncomingHttpHeaders } from 'node:http' @@ -29,42 +30,64 @@ function isLoopbackHostname(hostname: string): boolean { && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) } -/** Hostname of a Host-header authority (port stripped, lowercased, IPv6 bracketed), or undefined when unparsable. */ -function authorityHostname(authority: string): string | undefined { +/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ +function parseAuthority(authority: string): URL | undefined { try { // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. - return new URL(`http://${authority}`).hostname + return new URL(`http://${authority}`) } catch { return undefined } } +/** + * Whether the request authority matches a `trustedHosts` entry. An entry with + * an explicit port matches that exact authority; a port-less entry matches the + * hostname on any port (the shape the CLI derives for IP-literal LAN serving, + * where the bound port may be OS-assigned). Both sides compare through WHATWG + * normalization, so case and a redundant `:80` never decide trust. + */ +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { + return trustedHosts.some((entry) => { + const entryUrl = parseAuthority(entry) + if (entryUrl === undefined) return false + return /:\d+$/.test(entry) + ? entryUrl.host === hostUrl.host + : entryUrl.hostname === hostUrl.hostname + }) +} + /** * Decide whether one /api request may reach the RPC bridge. * @param request - node HTTP request facts (headers). - * @param trustedHosts - exact non-loopback `host[:port]` authorities this deployment serves. - * @returns true when the Host is ours and any browser markers are same-origin. + * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. + * @returns true for requests without browser markers, and for browser requests whose Host is ours and whose markers are same-origin. */ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { + // Marker gate: Origin and sec-fetch-site exist only when a browser is the + // sender's deputy. Absent both, the sender is the principal itself (curl, + // tests, native shells) and could forge every header below — fencing it + // would add nothing and would break non-browser LAN automation. + const origin = header(request.headers, 'origin') + const secFetchSite = header(request.headers, 'sec-fetch-site') + if (origin === undefined && secFetchSite === undefined) return true // Host fence (DNS-rebinding defense): the browser fills Host from the URL it // believes it is talking to, so a rebound page carries the attacker's domain // here even though the socket lands on this server. const host = header(request.headers, 'host') if (host === undefined) return false - const hostname = authorityHostname(host) - if (hostname === undefined) return false - if (!isLoopbackHostname(hostname) && !trustedHosts.includes(host)) return false + const hostUrl = parseAuthority(host) + if (hostUrl === undefined) return false + if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false // Cross-site fence: modern browsers label the initiator relationship on // every fetch; an explicit cross-site marker is refused regardless of Origin. - if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false + if (secFetchSite === 'cross-site') return false // Origin fence: when a browser attaches an Origin it must be exactly this - // authority. Absent Origin = non-browser client (curl, tests, native shells) - // — allowed, because without a browser there is no confused deputy. The + // authority (compared through the same normalization as the Host). The // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. - const origin = header(request.headers, 'origin') if (origin === undefined) return true try { - return new URL(origin).host === host + return new URL(origin).host === hostUrl.host } catch { return false } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 77f463149e..1bc39ed3ba 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -19,10 +19,11 @@ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { /** - * Exact `host[:port]` authorities this deployment serves beyond loopback. - * The /api trust fence refuses any request whose Host is neither loopback - * nor listed here, so a non-loopback (`0.0.0.0`) deployment must declare - * the names it is reached by. + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * browser request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). */ trustedHosts?: string[] } diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index eab878e734..5dc2d14b1b 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -8,14 +8,19 @@ function request(headers: Record): { headers: Record } describe('isTrustedApiRequest', () => { - it('accepts loopback Hosts in every spelling, with and without ports', () => { - for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { - expect(isTrustedApiRequest(request({ host }), [])).toBe(true) + it('accepts every request without browser markers — curl, tests, native clients, on any Host', () => { + // No Origin and no sec-fetch-site → the sender is the principal itself + // (it forges Host freely anyway); this is the LAN-serving shape a Host + // fence must not break. + for (const host of ['127.0.0.1:3080', '192.168.1.5:3080', 'harness.example', undefined]) { + expect(isTrustedApiRequest(request(host === undefined ? {} : { host }), [])).toBe(true) } }) - it('accepts non-browser requests (no Origin, no sec-fetch-site) — curl, tests, native clients', () => { - expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true) + it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => { + for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { + expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true) + } }) it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => { @@ -26,13 +31,22 @@ describe('isTrustedApiRequest', () => { }), [])).toBe(false) }) - it('accepts a declared public authority only on exact host[:port] match', () => { + it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => { const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' } expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true) - expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(false) + expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false) expect(isTrustedApiRequest(request(headers), [])).toBe(false) }) + it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => { + expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true) + // An unparsable entry never matches; it must not poison the rest of the list. + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false) + }) + it('refuses cross-origin browser markers even on a loopback Host', () => { // Origin present and different → cross-site request that survived preflight rules. expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false) @@ -42,19 +56,22 @@ describe('isTrustedApiRequest', () => { expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false) }) - it('accepts a same-origin browser request', () => { + it('accepts a same-origin browser request, with or without an Origin header', () => { expect(isTrustedApiRequest(request({ host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', }), [])).toBe(true) + // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site. + expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) }) - it('refuses malformed authorities', () => { - expect(isTrustedApiRequest(request({}), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: '' }), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: 'bad host' }), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: '127.0.0.999' }), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: '128.0.0.1' }), [])).toBe(false) + it('refuses malformed or untrusted authorities on browser requests', () => { + const markers = { 'sec-fetch-site': 'same-origin' } + expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index a492a7a9c0..59c8c34ce2 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -80,6 +80,11 @@ describe('connection node half', () => { const loopback = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) expect(loopback.state.status).toBe(404) + // Undeclared LAN authority, no browser markers: the `--host 0.0.0.0` curl + // shape must reach the bridge even with an empty-by-default trust list. + const lan = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) + expect(lan.state.status).toBe(404) // Declared public authority, same-origin browser shape. const declared = fakeResponse() await routes[0]!.handler(fakeRequest({ From b9cbe2f029fedf77222d9f02b8cfe2b9cfc30f14 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:57:02 +0800 Subject: [PATCH 2/3] fix(connection): fail the load on a trustedHosts entry that is not a bare authority WHATWG parsing would quietly read a hostname out of harness.internal/path or user@harness.internal, authorizing the typo's hostname; other typos would sit silently ignored until requests 403. Refuse every URL part beyond host[:port] at plugin load. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 ++-- .../2026-07-28-api-browser-trust-boundary.md | 2 +- ...026-07-28-api-browser-trust-boundary.zh.md | 2 +- docs/config-catalog.md | 3 ++- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 15 ++++++++++++ packages/client/connection/src/index.ts | 8 +++++-- .../tests/api-request-trust.spec.ts | 13 +++++++++- .../client/connection/tests/node-half.spec.ts | 24 +++++++++++++++++++ 11 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 68473ee893..1e10e92f49 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 45a332fcfe59fb930a85cfc595dd02c5fe12a5d7 -2026-07-28-api-browser-trust-boundary.zh.md: 731d6c81f71a2f50b716e52e278f2c53ad62a04b +2026-07-28-api-browser-trust-boundary.md: 4dd913bb73da3b24073c020ff80fdfa83b44a812 +2026-07-28-api-browser-trust-boundary.zh.md: 0be817aca9dde68588959d2cd622639d90d9f993 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 45a332fcfe..4dd913bb73 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. -- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo. `host.pickDirectory` loses its bespoke guard and rides the same fence. Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 731d6c81f7..0be817aca9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6155c418e0..5a7f63c85f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -282,7 +282,8 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * browser request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare authority fails the plugin load. */ trustedHosts?: string[] } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 8310adac83..f0775848d3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 94b9b3c8d4bde30cedf56e31d83efe9f5f1dd87c -README.zh.md: 844a2ef030378c32994f7459792db98c779f24b7 +README.md: 591e8361c1d28fab909bfe4a4f176fa1887edd93 +README.zh.md: bd772b2ab0f36abc8dbce35d30f55b40e53d0756 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 94b9b3c8d4..591e8361c1 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare `host[:port]` authority fails the plugin load loudly — WHATWG parsing would otherwise quietly authorize the hostname inside a typo like `harness.internal/path`. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 844a2ef030..bd772b2ab0 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯 `host[:port]` 权威的 `trustedHosts` 条目会让插件加载大声失败——否则 WHATWG 解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 ## 无密钥 fixture diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 11dc6d7621..2a8b8d7273 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -40,6 +40,21 @@ function parseAuthority(authority: string): URL | undefined { } } +/** + * Assert one configured `trustedHosts` entry is a bare authority (`host` or + * `host:port`) and nothing else. WHATWG parsing would quietly read a hostname + * out of `harness.internal/path` or `user@harness.internal` — a typo must fail + * the load loudly instead of authorizing its hostname or being ignored until + * requests 403. The delimiter test refuses every URL part beyond the authority + * (path, backslash path, query, fragment, userinfo); IPv6 brackets use none of + * them. + * @param entry - the configured value, verbatim. + */ +export function assertTrustedAuthority(entry: string): void { + if (parseAuthority(entry) !== undefined && !/[/\\?#@]/.test(entry)) return + throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) +} + /** * Whether the request authority matches a `trustedHosts` entry. An entry with * an explicit port matches that exact authority; a port-less entry matches the diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 1bc39ed3ba..f37a64fb21 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -6,7 +6,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { isTrustedApiRequest } from './api-request-trust.ts' +import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -23,7 +23,8 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * browser request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare authority fails the plugin load. */ trustedHosts?: string[] } @@ -42,6 +43,9 @@ export const Config: z = z.object({ export function apply(ctx: Context, config?: ConnectionConfig): void { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] + // Config boundary: a malformed entry fails the load loudly here rather than + // silently authorizing its hostname prefix at request time. + for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index 5dc2d14b1b..99df0d86eb 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -1,7 +1,7 @@ /** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */ import { describe, expect, it } from 'vitest' -import { isTrustedApiRequest } from '../src/api-request-trust.ts' +import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts' function request(headers: Record): { headers: Record } { return { headers } @@ -66,6 +66,17 @@ describe('isTrustedApiRequest', () => { expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) }) + it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => { + for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) { + expect(() => { assertTrustedAuthority(entry) }).not.toThrow() + } + // WHATWG parsing would quietly read a hostname out of each of these; the + // config boundary must refuse them instead of authorizing the prefix. + for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + }) + it('refuses malformed or untrusted authorities on browser requests', () => { const markers = { 'sec-fetch-site': 'same-origin' } expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 59c8c34ce2..5404f1c798 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -54,6 +54,30 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: } describe('connection node half', () => { + it('fails the load on a trustedHosts entry that is not a bare authority', async () => { + const routes: WebRoute[] = [] + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + // The apply throw also escapes cordis as a late rejection — the shape the + // boot's installFailLoud is contracted to catch. Capture it so the run + // stays clean, same pattern as the webserver bind-failure test. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) + await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/) + expect(routes).toHaveLength(0) + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority') + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('registers the /api prefix route and removes it with the fiber', async () => { const { routes, dispose } = await mounted() expect(routes).toHaveLength(1) From 34518cb012d3d9a16ec851b4a215db7100f4457a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 16:19:31 +0800 Subject: [PATCH 3/3] fix(connection): judge an entry's explicit port from the parsed URL, not the raw string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHATWG trimming strips stray whitespace before parsing, so 'host:port ' passed the load assert while the raw-string port regex read it as port-less — broadening an exact-port grant to every port on that hostname. The explicit- port judgment now reads URL parses under both special schemes (:80/:443 stay explicit), and the load assert refuses whitespace outright. --- .../connection/src/api-request-trust.ts | 21 +++++++++++++++---- .../tests/api-request-trust.spec.ts | 12 +++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 2a8b8d7273..ad2519d90b 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -45,16 +45,29 @@ function parseAuthority(authority: string): URL | undefined { * `host:port`) and nothing else. WHATWG parsing would quietly read a hostname * out of `harness.internal/path` or `user@harness.internal` — a typo must fail * the load loudly instead of authorizing its hostname or being ignored until - * requests 403. The delimiter test refuses every URL part beyond the authority - * (path, backslash path, query, fragment, userinfo); IPv6 brackets use none of + * requests 403. The character test refuses every URL part beyond the authority + * (path, backslash path, query, fragment, userinfo) and all whitespace, which + * WHATWG trimming would otherwise strip silently; IPv6 brackets use none of * them. * @param entry - the configured value, verbatim. */ export function assertTrustedAuthority(entry: string): void { - if (parseAuthority(entry) !== undefined && !/[/\\?#@]/.test(entry)) return + if (parseAuthority(entry) !== undefined && !/[/\\?#@\s]/.test(entry)) return throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) } +/** + * Whether the parsed authority carries an explicit port: judged from URL + * parses under both special schemes (their default ports differ, so `:80` and + * `:443` still count as explicit), never from the raw string, where WHATWG + * trimming of stray whitespace would misread `host:port ` as port-less and + * broaden an exact-port grant to every port. + */ +function hasExplicitPort(entry: string, entryUrl: URL): boolean { + // An authority that parsed under http cannot fail under https. + return entryUrl.port !== '' || new URL(`https://${entry}`).port !== '' +} + /** * Whether the request authority matches a `trustedHosts` entry. An entry with * an explicit port matches that exact authority; a port-less entry matches the @@ -66,7 +79,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool return trustedHosts.some((entry) => { const entryUrl = parseAuthority(entry) if (entryUrl === undefined) return false - return /:\d+$/.test(entry) + return hasExplicitPort(entry, entryUrl) ? entryUrl.host === hostUrl.host : entryUrl.hostname === hostUrl.hostname }) diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index 99df0d86eb..e3f1c91caf 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -75,6 +75,18 @@ describe('isTrustedApiRequest', () => { for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) { expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) } + // WHATWG trimming would silently strip these; the entry must fail instead. + for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + }) + + it('never lets stray whitespace broaden an exact-port entry to every port', () => { + // Defense in depth below the load-time assert: the explicit-port judgment + // reads the parsed URL, so a trimmed `host:port ` entry stays exact. + const trusted = ['harness.internal:3080 '] + expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true) }) it('refuses malformed or untrusted authorities on browser requests', () => {