fix(gui): harden multimodal image attachments

This commit is contained in:
Yichen Jiang
2026-07-23 19:38:37 +08:00
parent eea595fcb4
commit 580e05b794
61 changed files with 1700 additions and 214 deletions

View File

@@ -2,7 +2,7 @@
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply the bind `host`, `port`, and positive `maxRequestBodyBytes`; port `0` requests an OS-assigned port and the running handle reports the assigned value. The API bridge returns 413 before buffering a declared oversized body and keeps chunked-body buffering within the same cap. `dsh web` derives its default cap from the configured aggregate image limit plus base64/envelope expansion and accepts `--max-request-body-bytes` as an explicit override. It defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.

View File

@@ -33,6 +33,8 @@ export interface WebServerOptions {
distIndex: string
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
apiHandler: { fetch: typeof fetch }
/** Maximum buffered bytes accepted for one `/api/*` request body. */
maxRequestBodyBytes: number
/**
* Web plugin table. When present, every index.html response carries a
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
@@ -66,7 +68,10 @@ export interface RunningWebServer {
* @returns the running server handle once listening.
*/
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
const { host, port, distIndex, apiHandler, webPlugins } = options
const { host, port, distIndex, apiHandler, maxRequestBodyBytes, webPlugins } = options
if (!Number.isInteger(maxRequestBodyBytes) || maxRequestBodyBytes < 1) {
throw new RangeError('host webserver: maxRequestBodyBytes must be a positive integer')
}
const distRoot = dirname(distIndex)
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
const html = await readFile(distIndex, 'utf8')
@@ -78,7 +83,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
requests; the field is only optional on the client-side IncomingMessage type */
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
if (rawPath.startsWith('/api/')) {
await bridge(req, res, apiHandler)
await bridge(req, res, apiHandler, maxRequestBodyBytes)
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -163,8 +168,13 @@ async function servePluginBundle(
}
}
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
/** Bridge one bounded node:http request to the WHATWG fetch handler. */
async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: { fetch: typeof fetch },
maxRequestBodyBytes: number,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
@@ -174,8 +184,31 @@ async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { f
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
res.writeHead(413)
res.end()
req.resume()
return
}
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
let received = 0
let oversized = false
for await (const chunk of req) {
const buffer = chunk as Buffer
received += buffer.byteLength
if (received > maxRequestBodyBytes) {
oversized = true
chunks.length = 0
continue
}
if (!oversized) chunks.push(buffer)
}
if (oversized) {
res.writeHead(413)
res.end()
return
}
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {

View File

@@ -1,10 +1,13 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { request as httpRequest } from 'node:http'
import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startWebServer, type RunningWebServer } from '../src/index.ts'
const MAX_REQUEST_BODY_BYTES = 64 * 1024
/** Reserve a loopback port for tests that need to address a second server. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -104,17 +107,35 @@ afterEach(async () => {
server = undefined
})
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
async function boot(
onError: (err: Error) => void = () => undefined,
maxRequestBodyBytes = MAX_REQUEST_BODY_BYTES,
): Promise<string> {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
server = await startWebServer({
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes,
}, onError)
return `http://127.0.0.1:${String(server.port)}`
}
describe('startWebServer', () => {
it('rejects an invalid request-body cap before listening', () => {
const { distIndex } = makeDist()
expect(() => startWebServer({
host: '127.0.0.1',
port: 0,
distIndex,
apiHandler: echoingApi,
maxRequestBodyBytes: 0,
}, () => undefined)).toThrow(/positive integer/)
})
it('reports the listening port and closes idempotently', async () => {
const { distIndex } = makeDist()
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
server = await startWebServer({
host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
}, () => undefined)
expect(server.port).toBeGreaterThan(0)
const first = server.close()
const second = server.close()
@@ -136,7 +157,9 @@ describe('startWebServer', () => {
})
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
try {
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
const inertServer = await startWebServer({
host, port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
}, () => undefined)
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
await inertServer.close()
} finally {
@@ -148,8 +171,12 @@ describe('startWebServer', () => {
it('rejects when the port is already taken', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
server = await startWebServer({
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
}, () => undefined)
await expect(startWebServer({
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
}, () => undefined))
.rejects.toMatchObject({ code: 'EADDRINUSE' })
})
})
@@ -207,7 +234,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
}
const port = await freePort()
server = await startWebServer(
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
{
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi,
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins,
}, () => undefined,
)
return `http://127.0.0.1:${String(server.port)}`
}
@@ -245,7 +275,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
}
const port = await freePort()
server = await startWebServer(
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
{
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi,
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins,
}, () => undefined,
)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
expect(res.status).toBe(404)
@@ -305,6 +338,35 @@ describe('/api bridge', () => {
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
})
it('returns 413 before buffering a declared oversized body', async () => {
const base = await boot()
const response = await fetch(`${base}/api/echo`, {
method: 'POST',
body: 'x'.repeat(MAX_REQUEST_BODY_BYTES + 1),
})
expect(response.status).toBe(413)
})
it('bounds chunked request buffering when no content length is declared', async () => {
const base = await boot(() => undefined, 8)
const target = new URL(`${base}/api/echo`)
const status = await new Promise<number | undefined>((resolve, reject) => {
const request = httpRequest({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: 'POST',
}, (response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode) })
})
request.on('error', reject)
request.write('12345')
request.end('67890')
})
expect(status).toBe(413)
})
it('relays a bodyless response', async () => {
const base = await boot()
const response = await fetch(`${base}/api/empty`, { method: 'POST' })