Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user