fix(connection): fence every /api request behind one browser-trust check
The only browser-trust guard covered host.pickDirectory, while the consequential methods (session.prompt drives bash) accepted any Host — open to DNS rebinding, where a rebound page reads and writes the API as if same-origin and only the Host header betrays the attacker's domain. The pickDirectory-specific loopback guard becomes a prefix-wide fence: Host must be loopback or an exact host[:port] from the new trustedHosts config, an attached Origin must equal that authority, and explicit cross-site markers are refused; requests without browser markers (curl, tests, native clients) pass, because without a browser there is no confused deputy. The loopback-socket check is dropped — binding policy expresses reachability, and the fence is not an auth layer. The Agent Note records the full threat model and the alternatives.
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: a301b85d707d17d1e8159655b540556eee5c9d83
|
||||
README.zh.md: 88d7aa806167033308a9913053f621ecea07c3d8
|
||||
|
||||
@@ -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`): 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).
|
||||
|
||||
## 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]` 精确匹配(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)。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"@deepseek-ai/dsh-host-apiproxy": "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",
|
||||
|
||||
71
packages/client/connection/src/api-request-trust.ts
Normal file
71
packages/client/connection/src/api-request-trust.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 — 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.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Hostname of a Host-header authority (port stripped, lowercased, IPv6 bracketed), or undefined when unparsable. */
|
||||
function authorityHostname(authority: string): string | undefined {
|
||||
try {
|
||||
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
|
||||
return new URL(`http://${authority}`).hostname
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
||||
// 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
|
||||
// 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. Absent Origin = non-browser client (curl, tests, native shells)
|
||||
// — allowed, because without a browser there is no confused deputy. 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
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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 { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
@@ -15,19 +16,37 @@ 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 {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 ?? []
|
||||
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`
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/** Trust check for browser requests that can open an operating-system dialog. */
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
60
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
60
packages/client/connection/tests/api-request-trust.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/** 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'
|
||||
|
||||
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
|
||||
return { headers }
|
||||
}
|
||||
|
||||
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 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('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 only on exact host[:port] match', () => {
|
||||
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), [])).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', () => {
|
||||
expect(isTrustedApiRequest(request({
|
||||
host: 'localhost:3080',
|
||||
origin: 'http://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)
|
||||
})
|
||||
})
|
||||
@@ -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,46 +8,84 @@ 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'
|
||||
|
||||
/** 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,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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('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)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const { routes, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url: '/api/host.pickDirectory',
|
||||
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'] })
|
||||
// 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)
|
||||
// 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