Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
This commit is contained in:
@@ -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: 80228a180faba0c556ff720e999b29b5bb1635b6
|
||||
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
|
||||
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4
|
||||
|
||||
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. 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
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `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
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
129
packages/client/connection/src/api-request-trust.ts
Normal file
129
packages/client/connection/src/api-request-trust.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Browser-trust fence for every /api request. Defends the two confused-deputy
|
||||
* 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. The Host fence binds every request,
|
||||
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
||||
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
|
||||
* 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'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/** 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}`)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
||||
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
|
||||
* (case aside). Anything parsing would silently rewrite is refused as a typo
|
||||
* that must fail the load loudly instead of being ignored until requests 403
|
||||
* or quietly changing the grant: URL parts beyond the authority
|
||||
* (`harness.internal/path`, `user@harness.internal` — which would authorize
|
||||
* the embedded hostname), stripped whitespace, a dangling colon or
|
||||
* zero-padded port (which would broaden an intended exact-port grant to every
|
||||
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
|
||||
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
|
||||
* carries).
|
||||
* @param entry - the configured value, verbatim.
|
||||
*/
|
||||
export function assertTrustedAuthority(entry: string): void {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
|
||||
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of a parsed authority: `hostname` when no port was written,
|
||||
* else `hostname:port`. The port is 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 would misread
|
||||
* shapes like `host:port ` as port-less.
|
||||
*/
|
||||
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
||||
// An authority that parsed under http cannot fail under https.
|
||||
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
||||
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${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
|
||||
* 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 canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
||||
? entryUrl.hostname === hostUrl.hostname
|
||||
: entryUrl.host === hostUrl.host
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
||||
// Host fence (DNS-rebinding defense), applied to every request: 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. There is no marker shortcut — a browser read over plain HTTP
|
||||
// (EventSource, images, navigations) arrives with neither Origin nor
|
||||
// Fetch-Metadata, indistinguishable from curl, and its response is readable
|
||||
// by the rebound page.
|
||||
const host = header(request.headers, 'host')
|
||||
if (host === undefined) 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
|
||||
// Origin fence: when a browser attaches an Origin it must be exactly this
|
||||
// authority (compared through the same normalization as the Host). Absent
|
||||
// Origin is fine — the Host fence above already bound the request. 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 === hostUrl.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
|
||||
@@ -578,6 +578,37 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
updatedAt: fixtureEpoch,
|
||||
}]
|
||||
let nextWorkspace = 1
|
||||
|
||||
// In-memory browse tree behind the fixture's `browse` picker capability —
|
||||
// deterministic content mirroring the design mock so assembled Web tests
|
||||
// and snapshots can walk it. Leaves are materialized lazily: a child listed
|
||||
// by its parent lists as empty until something is created inside it.
|
||||
const FIXTURE_HOME = '/home/fixture'
|
||||
const directoryTree = new Map<string, string[]>([
|
||||
['/', ['home']],
|
||||
['/home', ['fixture']],
|
||||
[FIXTURE_HOME, ['Documents', 'Downloads', '.config']],
|
||||
[`${FIXTURE_HOME}/Documents`, [
|
||||
'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform',
|
||||
'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog',
|
||||
]],
|
||||
])
|
||||
const childrenOf = (path: string): string[] | undefined => {
|
||||
const known = directoryTree.get(path)
|
||||
if (known !== undefined) return known
|
||||
const parent = path.slice(0, path.lastIndexOf('/')) || '/'
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
return directoryTree.get(parent)?.includes(name) === true ? [] : undefined
|
||||
}
|
||||
const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => {
|
||||
const crumbs = [{ name: '/', path: '/', hidden: false }]
|
||||
let acc = ''
|
||||
for (const segment of path.split('/').filter(Boolean)) {
|
||||
acc += `/${segment}`
|
||||
crumbs.push({ name: segment, path: acc, hidden: false })
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||
const pendingApprovalRpcId = mint()
|
||||
@@ -983,7 +1014,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
pickDirectory: request => ok(request, { path: null }),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
// pick-then-adopt path without an OS chooser (design-mock content,
|
||||
// same tree the browse primitives serve).
|
||||
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
|
||||
listDirectory: (request) => {
|
||||
const target = request.payload.path ?? FIXTURE_HOME
|
||||
const children = childrenOf(target)
|
||||
if (children === undefined) {
|
||||
return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
|
||||
}
|
||||
return ok(request, {
|
||||
path: target,
|
||||
home: FIXTURE_HOME,
|
||||
crumbs: crumbsOf(target),
|
||||
entries: [...children].sort((a, b) => a.localeCompare(b))
|
||||
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
|
||||
// The fixture tree is tiny; no level ever reaches a backend bound.
|
||||
truncated: false,
|
||||
})
|
||||
},
|
||||
createDirectory: (request) => {
|
||||
const parent = request.payload.path
|
||||
const children = childrenOf(parent)
|
||||
if (children === undefined) {
|
||||
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
|
||||
}
|
||||
// Same root special case as listDirectory's entry paths: a plain join
|
||||
// under '/' would mint '//name' and fork the tree's identity.
|
||||
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
|
||||
if (children.includes(request.payload.name)) {
|
||||
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
|
||||
}
|
||||
directoryTree.set(parent, [...children, request.payload.name])
|
||||
directoryTree.set(target, [])
|
||||
return ok(request, { path: target })
|
||||
},
|
||||
openPath: request => ok(request, { opened: true as const }),
|
||||
},
|
||||
workspace: {
|
||||
@@ -1338,6 +1404,8 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
case 'host.createDirectory': return this.api.host.createDirectory(request)
|
||||
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Activates the httpServer Context merge used below.
|
||||
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 { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
@@ -15,20 +16,42 @@ export const name = 'client-connection'
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
/**
|
||||
* Authorities this deployment serves beyond loopback: exact `host:port`, or
|
||||
* port-less `host` matching any port. 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 (the dsh CLI derives the machine's LAN IP literals itself). An entry
|
||||
* that is not a bare, canonical authority fails the plugin load.
|
||||
*/
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
export const Config: z<ConnectionConfig> = z.object({
|
||||
trustedHosts: z.array(String).default([]),
|
||||
})
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* Mounts the API gateway under the browser transport prefix. Every request on
|
||||
* the prefix passes the browser-trust fence first (DNS-rebinding and
|
||||
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
|
||||
* @param ctx - Host plugin context.
|
||||
* @param config - resolved plugin config (schema defaults applied).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
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',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if ((pathname === `${API_PATH}/host.pickDirectory`
|
||||
|| pathname === `${API_PATH}/host.openPath`)
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/** Trust check for browser requests that can invoke privileged native host actions. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
interface NativeDialogRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
socket: { remoteAddress?: string | undefined }
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopback(address: string | undefined): boolean {
|
||||
if (address === undefined) return false
|
||||
if (address === '::1') return true
|
||||
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
|
||||
const first = ipv4.split('.')[0]
|
||||
return first === '127'
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local socket plus browser-controlled same-origin metadata.
|
||||
* @param request - the node HTTP request facts used by the carrier guard.
|
||||
* @returns true only for a same-origin browser request whose peer and URL are loopback.
|
||||
*/
|
||||
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
|
||||
if (!isLoopback(request.socket.remoteAddress)) return false
|
||||
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
|
||||
const origin = header(request.headers, 'origin')
|
||||
const host = header(request.headers, 'host')
|
||||
if (origin === undefined || host === undefined) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
const hostUrl = new URL(`http://${host}`)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
&& parsed.host === host
|
||||
&& isLoopbackHostname(parsed.hostname)
|
||||
&& isLoopbackHostname(hostUrl.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
108
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
108
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
|
||||
|
||||
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
|
||||
return { headers }
|
||||
}
|
||||
|
||||
describe('isTrustedApiRequest', () => {
|
||||
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
|
||||
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
|
||||
// reads (EventSource, images, navigations), so a rebound-origin GET is
|
||||
// markerless and its response readable: no marker shortcut may exist.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({}), [])).toBe(false)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
expect(isTrustedApiRequest(request({
|
||||
host: 'evil.example:3080',
|
||||
origin: 'http://evil.example:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}), [])).toBe(false)
|
||||
})
|
||||
|
||||
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(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)
|
||||
// Explicit cross-site label → refused regardless of Origin.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
|
||||
// Opaque origin (sandboxed iframe, file: page) parses to no authority.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
|
||||
})
|
||||
|
||||
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('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/)
|
||||
}
|
||||
// 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/)
|
||||
}
|
||||
// WHATWG parsing would silently rewrite these — a dangling colon or
|
||||
// zero-padded port would broaden an intended exact-port grant to every
|
||||
// port, and non-canonical host spellings would not read back as written.
|
||||
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
|
||||
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', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -70,6 +70,18 @@ export class FakeApiClient implements IApiClient {
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
@@ -91,6 +103,8 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
|
||||
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
|
||||
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
|
||||
@@ -323,6 +323,22 @@ describe('createFixtureApi', () => {
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.path).toBe('/srv')
|
||||
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.crumbs).toEqual([
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
])
|
||||
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
|
||||
if (!root.result.ok) throw new Error('root list failed')
|
||||
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
|
||||
})
|
||||
|
||||
it('workspace.list serves the resident account and create reuses on path collision', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.workspace.list(req({}))
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
||||
|
||||
function request(
|
||||
remoteAddress: string | undefined,
|
||||
headers: IncomingHttpHeaders = {
|
||||
host: '127.0.0.1:3080',
|
||||
origin: 'http://127.0.0.1:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
) {
|
||||
return { socket: { remoteAddress }, headers }
|
||||
}
|
||||
|
||||
describe('native dialog request trust', () => {
|
||||
it('accepts loopback same-origin browser requests', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::1', {
|
||||
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
|
||||
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects remote sockets and requests without matching browser metadata', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
@@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
|
||||
function fakeRequest(headers: Record<string, string>): IncomingMessage {
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
}) as unknown as ServerResponse
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
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)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url,
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
const { response, state } = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
}), response)
|
||||
expect(state.status).toBe(403)
|
||||
expect(state.body).toBe('forbidden')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('passes loopback and declared-authority requests through to the bridge', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
|
||||
// Loopback, no browser markers (curl shape): the fence passes; the carrier
|
||||
// answers 404 for a GET unary path — proof the bridge ran.
|
||||
const loopback = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
|
||||
expect(loopback.state.status).toBe(404)
|
||||
// LAN authority declared as a port-less IP literal — the shape the CLI
|
||||
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
|
||||
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({
|
||||
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}), declared.response)
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user