fix(web-config): close the wire boundary, the redacted-replace data loss, and three P2s
Five findings from the #939 review, each reproduced before being fixed. **Configuration reads are as privileged as writes.** `settings.describe` returns every exposed namespace's configuration and `credentials.describe` reports whether an arbitrary environment-variable name is configured and from where — reconnaissance no anonymous caller should have. Both join PRIVILEGED_METHODS, so the whole configuration plane is loopback-only until real authentication exists; `trustedHosts` was never authentication. The model catalog stays reachable: it carries no endpoints or key state, and a LAN client's model picker legitimately needs it. Asserted over a real HTTP server, because the Host header a browser actually sends is what decides this. **The proxy serves only namespaces a registered model provider addresses.** The settings seam is general — any plugin may register one — but the Web configuration plane is the model-provider surface. Without the gate, every future `settings.register()` would silently become remotely readable and writable configuration. An unregistered namespace and an unexposed one answer identically, so no caller can enumerate the registry one probe at a time. **Path-addressed writes replace the redacted-document rebuild.** The editor reads the REDACTED descriptor, so rebuilding a section from it and replacing wholesale deleted every literal secret the wire never returned — reproduced as `{baseURL, reasoning}` in, stored `apiKey` gone out. `settings.mutate` applies set/unset ops to the section as it stands at the front of the seam's write queue, and the client names only fields it can see, so an unseen secret is untouched by construction rather than by care. P2s in the same pass: `llm/adapters-updated` now contains async listener rejections (an uncontained one escaped as unhandledRejection, contradicting the documented "observer failures are contained"); llm-deepseek's retry-policy swap uses the atomic `registration.replace` instead of dispose-then-register, which published `[]` then `["deepseek-official"]` so an observer saw the provider disappear and come back; and a transport rejection no longer strands the page in `loading` or a card in `busy`, with removal failures surfaced on the page banner instead of swallowed.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
@@ -99,14 +101,14 @@ describe('connection node half', () => {
|
||||
|
||||
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
// The privileged set: native dialogs plus every settings/credential write.
|
||||
// The same declared authority reaches ordinary reads (carrier-level 404
|
||||
// from the empty proxy proves the fence passed), but each privileged
|
||||
// method stays loopback-only and short-circuits 403.
|
||||
// The privileged set: native dialogs plus the whole settings/credential
|
||||
// configuration plane, reads included. The same declared authority reaches
|
||||
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.update', 'settings.replace',
|
||||
'credentials.set', 'credentials.unset',
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -143,3 +145,69 @@ describe('connection node half', () => {
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
/** Serve the registered prefix route from a real server and return its port. */
|
||||
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = createServer((request, response) => {
|
||||
void routes[0]!.handler(request, response)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as AddressInfo
|
||||
return {
|
||||
port: address.port,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined || error === null) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
|
||||
function call(port: number, method: string, host: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
|
||||
(response) => {
|
||||
response.resume()
|
||||
response.on('end', () => { resolve(response.statusCode ?? 0) })
|
||||
},
|
||||
)
|
||||
request.on('error', reject)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
|
||||
// The fence's input is a real IncomingMessage parsed by Node from the
|
||||
// wire, not a hand-assembled object: the Host header a LAN browser sends
|
||||
// is exactly what decides loopback-only here, so the boundary is asserted
|
||||
// against the parse the server actually performs.
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
const { port, close } = await serve(routes)
|
||||
try {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
|
||||
} finally {
|
||||
await close()
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user