Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

This commit is contained in:
creatixchu
2026-07-28 16:42:36 +08:00
20 changed files with 281 additions and 68 deletions
@@ -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,92 @@ 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
}
}
/**
* 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 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 && !/[/\\?#@\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
* 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 hasExplicitPort(entry, entryUrl)
? 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
}
+10 -5
View File
@@ -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'
@@ -19,10 +19,12 @@ 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). An entry
* that is not a bare authority fails the plugin load.
*/
trustedHosts?: string[]
}
@@ -41,6 +43,9 @@ export const Config: z<ConnectionConfig> = 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',