diff --git a/dsh-pet/runtime/electron-helper/main.js b/dsh-pet/runtime/electron-helper/main.js index da3daef7..ca735631 100644 --- a/dsh-pet/runtime/electron-helper/main.js +++ b/dsh-pet/runtime/electron-helper/main.js @@ -77,6 +77,8 @@ app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // 双击启动的用户没有终端可按 Ctrl+C,托盘上必须有一个「退出」,否则只能去任务管理器杀进程。 // DSH 内的桌面模式不设这个变量,托盘形态与 userData 目录都保持原样。 const APP_MODE = process.env.DSH_PET_APP_MODE === '1'; +// Captured only for in-process app requests; never exposed to renderer URLs or logs. +const APP_TOKEN = APP_MODE ? process.env.DSH_PET_APP_TOKEN : undefined; // 名字必须与 src/host/storage-paths.ts 的 DESKTOP_APP_NAME 逐字一致 // (storage-paths.test.ts 会直接读本文件比对,改名不同步测试就红)。 app.setName('dsh-pet-electron-helper'); @@ -691,7 +693,10 @@ async function hostRequest(method, url, body) { if (BRIDGE) return bridgeRequest(method, url, body); const resp = await fetch(ORIGIN + url, { method, - headers: body ? { 'content-type': 'application/json' } : undefined, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...(APP_TOKEN ? { 'X-DSH-Pet-Token': APP_TOKEN } : {}), + }, body, }); return { status: resp.status, body: await resp.text() }; diff --git a/dsh-pet/src/app/desktop-auth.test.ts b/dsh-pet/src/app/desktop-auth.test.ts new file mode 100644 index 00000000..da3462ba --- /dev/null +++ b/dsh-pet/src/app/desktop-auth.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { installDesktopAuth, type DesktopSession } from './desktop-auth.ts'; + +test('only the local main renderer receives the capability, scoped to the API origin', () => { + let listener: Parameters[0] | undefined; + const rendererPath = resolve('runtime/electron-helper/index.html'); + const renderer = pathToFileURL(rendererPath).href; + installDesktopAuth( + { + webRequest: { + onBeforeSendHeaders(fn) { + listener = fn; + }, + }, + }, + 'http://127.0.0.1:3080', + rendererPath, + 'private-token', + ); + assert.ok(listener); + const send = (url: string, frame?: { url: string; parent: unknown } | null) => { + let result: Record = {}; + listener!( + { url, frame, requestHeaders: { 'content-type': 'application/json', 'x-dsh-pet-token': 'stale-token' } }, + (response) => { + result = response.requestHeaders; + }, + ); + return result; + }; + const endpoint = 'http://127.0.0.1:3080/dsh-pet-7340/chat'; + const ownFrame = { url: renderer + '?petIndex=0', parent: null }; + assert.equal(send(endpoint, ownFrame)['X-DSH-Pet-Token'], 'private-token'); + for (const frame of [ + undefined, + null, + { url: 'about:srcdoc', parent: {} }, + { url: renderer, parent: {} }, + { url: 'https://example.invalid/', parent: null }, + { url: renderer + '.other', parent: null }, + ]) { + const headers = send(endpoint, frame); + assert.equal( + Object.keys(headers).some((name) => name.toLowerCase() === 'x-dsh-pet-token'), + false, + ); + assert.equal(headers['content-type'], 'application/json'); + } + for (const url of [ + 'http://127.0.0.1:3081/dsh-pet-7340/chat', + 'http://localhost:3080/dsh-pet-7340/chat', + 'https://example.invalid/dsh-pet-7340/chat', + 'http://127.0.0.1:3080/standalone-ai/settings', + 'invalid-url', + ]) { + assert.equal(send(url, ownFrame)['X-DSH-Pet-Token'], undefined); + } +}); diff --git a/dsh-pet/src/app/desktop-auth.ts b/dsh-pet/src/app/desktop-auth.ts new file mode 100644 index 00000000..7e783877 --- /dev/null +++ b/dsh-pet/src/app/desktop-auth.ts @@ -0,0 +1,48 @@ +import { pathToFileURL } from 'node:url'; + +interface RequestDetails { + url: string; + requestHeaders: Record; + frame?: { url: string; parent: unknown } | null; +} + +export interface DesktopSession { + webRequest: { + onBeforeSendHeaders( + listener: ( + details: RequestDetails, + callback: (response: { requestHeaders: Record }) => void, + ) => void, + ): void; + }; +} + +/** Keep the capability in the main process, scoped to our local top-level renderer and API. */ +export function installDesktopAuth(session: DesktopSession, origin: string, rendererPath: string, token: string): void { + const renderer = pathToFileURL(rendererPath).href; + session.webRequest.onBeforeSendHeaders((details, callback) => { + const headers = { ...details.requestHeaders }; + // A redirected request must not carry the capability to another destination. + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === 'x-dsh-pet-token') delete headers[name]; + } + try { + const target = new URL(details.url); + const frame = details.frame; + const page = frame ? new URL(frame.url) : undefined; + if ( + target.origin === origin && + target.pathname.startsWith('/dsh-pet-7340/') && + frame?.parent === null && + page?.protocol === 'file:' && + page.origin === 'null' && + page.href.split(/[?#]/, 1)[0] === renderer + ) { + headers['X-DSH-Pet-Token'] = token; + } + } catch { + // Missing/destroyed frames and invalid URLs never receive a capability. + } + callback({ requestHeaders: headers }); + }); +} diff --git a/dsh-pet/src/app/main.ts b/dsh-pet/src/app/main.ts index 93fc7c09..f476e48f 100644 --- a/dsh-pet/src/app/main.ts +++ b/dsh-pet/src/app/main.ts @@ -15,17 +15,15 @@ * POST /standalone-ai/test 连通性测试 * * /dsh-pet-7340/... 插件路由(桌面窗口用,因此额外放行 CORS) * - * 与 CLI 的一处差异:插件路由要服务 `file://` 页面,浏览器请求不带令牌,所以这一组只按 - * Origin 判定(允许空 / null,拒绝网页来源),不套令牌;配置页那组仍然严格校验令牌与 Host。 + * 插件路由也检查令牌与 Host;本地 file 页面由主进程给请求附加访问凭证, + * null 来源本身不授予权限。凭证不写入页面地址、渲染脚本或日志。 * * 环境变量(应用自身用,不影响 DSH 内运行): * DSH_PET_IN_PROCESS=1 交给宿主半边:不 spawn helper、不下载 Electron(见 src/host/index.ts) * DSH_PET_APP_MODE=1 交给 helper:托盘加「退出」、userData 独立 * DSH_PET_SITE_URL 交给 helper:右键「打开网站」用它(这里指向带令牌的配置页) */ -import { createServer } from 'node:http'; -import type { ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; + import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -37,11 +35,10 @@ import { createStandaloneAi } from '../standalone/ai-provider'; import { packageVersion } from '../standalone/check'; import { createStandaloneContext, createStandaloneLogger, type RegisteredRoute } from '../standalone/context'; import { configPathsFor } from '../standalone/options'; -import { createRequestSecurity, rejectRequest } from '../standalone/request-security'; -import type { RequestSecurity } from '../standalone/request-security'; -import { settingsHandler, testHandler } from '../standalone/server'; -import { settingsPage, settingsScript, settingsStyle } from '../standalone/settings-page'; -import type { StandaloneAiService } from '../standalone/ai-provider'; +import { createRequestSecurity } from '../standalone/request-security'; + +import { installDesktopAuth, type DesktopSession } from './desktop-auth'; +import { startRouter } from './router'; /** * `electron` 由 Electron 运行时提供,**不是本包的依赖**。只声明实际用到的成员: @@ -54,13 +51,17 @@ interface WebContentsLike { ): void; } interface ElectronApp { + whenReady(): Promise; requestSingleInstanceLock(): boolean; exit(code?: number): void; on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsLike) => void): void; on(event: 'second-instance', listener: () => void): void; on(event: 'will-quit', listener: () => void): void; } -const { app } = createRequire(import.meta.url)('electron') as { app: ElectronApp }; +const { app, session } = createRequire(import.meta.url)('electron') as { + app: ElectronApp; + session: { defaultSession: DesktopSession }; +}; /** lib/app.js → 包根(与 helper-process.ts 的 packageRoot 同一口径:模块所在目录的上一级) */ const here = dirname(fileURLToPath(import.meta.url)); @@ -98,7 +99,7 @@ async function boot(): Promise { authenticatedUrl: security.authenticatedUrl, }); - const server = await startRouter(routes, { security, ai }); + const server = await startRouter(routes, { security, ai, log, status, landingPage }); port = server.port; const origin = `http://127.0.0.1:${port}`; log(`本机服务 ${origin}`); @@ -127,7 +128,21 @@ async function boot(): Promise { log(`渲染端[${level}] ${message} (${source}:${line})`); }); }); - createRequire(import.meta.url)(helperMain); + // Register before helper's whenReady callback creates any file renderer. + void app.whenReady().then(() => { + installDesktopAuth( + session.defaultSession, + origin, + join(packageRoot, 'runtime/electron-helper/index.html'), + security.token, + ); + }); + process.env.DSH_PET_APP_TOKEN = security.token; + try { + createRequire(import.meta.url)(helperMain); + } finally { + delete process.env.DSH_PET_APP_TOKEN; + } app.on('second-instance', () => log('已有一个实例在运行,忽略本次启动')); app.on('will-quit', () => { @@ -155,111 +170,6 @@ function desktopPets(): Array<{ id: string; size: number }> { } } -interface RouterDeps { - security: RequestSecurity; - ai: StandaloneAiService; -} - -/** - * 本机 HTTP 面:插件路由 + 独立模式那套「状态 / 配置页」。 - * - * 为什么不能直接把 `listenStandaloneServer` 搬来用:它的 `authorize()` 要求请求带 Host 校验 - * 与同源 Origin,而桌面窗口是 `file://` 页面(Origin 为 `null`、Sec-Fetch-Site 为 cross-site), - * 会被它整组拒掉。所以这里按**两类请求分开**处理:配置页走完整的安全模型,插件路由只做 - * 「拒绝网页来源」这一条(否则本机任意网页都能调 /whisper、/chat 花掉模型额度)。 - */ -function startRouter( - routes: RegisteredRoute[], - deps: RouterDeps, -): Promise<{ port: number; close: () => Promise }> { - const settings = settingsHandler(deps.ai); - const tester = testHandler(deps.ai); - /** 监听后才知道端口:落地页/状态页要显示它 */ - let boundPort = 0; - - const server = createServer((req, res) => { - const url = new URL(req.url ?? '/', 'http://127.0.0.1'); - const method = req.method ?? 'GET'; - - if (method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - // ---- 配置页:与 CLI 完全一致(静态资源公开,读写接口要令牌)---- - if (url.pathname === '/standalone-ai/') { - sendText(res, 200, 'text/html', settingsPage); - return; - } - if (url.pathname === '/standalone-ai/settings.js') { - sendText(res, 200, 'text/javascript', settingsScript); - return; - } - if (url.pathname === '/standalone-ai/settings.css') { - sendText(res, 200, 'text/css', settingsStyle); - return; - } - if (url.pathname === '/standalone-ai/settings' || url.pathname === '/standalone-ai/test') { - if (!deps.security.authorize(req, res)) return; - const handler = url.pathname === '/standalone-ai/test' ? tester : settings; - void handler(req, res).catch((error: unknown) => { - log(`配置页接口失败 ${url.pathname}: ${error instanceof Error ? error.message : String(error)}`); - if (!res.headersSent) rejectRequest(res, 500, '配置页暂时无法处理请求'); - else res.end(); - }); - return; - } - - // ---- 状态与落地页 ---- - if (url.pathname === '/health') { - sendJson(res, status(boundPort)); - return; - } - if (url.pathname === '/') { - sendText(res, 200, 'text/html', landingPage(boundPort)); - return; - } - - // ---- 插件路由(桌面窗口)---- - const route = routes.find((candidate) => - candidate.kind === 'exact' - ? url.pathname === candidate.path - : url.pathname === candidate.path || url.pathname.startsWith(`${candidate.path}/`), - ); - if (!route) { - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); - res.end('not found'); - return; - } - const origin = req.headers.origin; - if (origin !== undefined && origin !== 'null') { - // 来自网页的跨站请求:本机任意网站都能打这几个端点(含 /chat、/whisper 会花钱) - rejectRequest(res, 403, '不允许网页来源访问'); - return; - } - // 窗口是 file:// 页面,属于跨源请求:插件 handler 不写 CORS,这里统一补 - res.setHeader('access-control-allow-origin', '*'); - Promise.resolve(route.handler(req, res)).catch((error: unknown) => { - log(`路由失败 ${url.pathname}: ${error instanceof Error ? error.message : String(error)}`); - if (!res.headersSent) res.writeHead(500); - res.end(); - }); - }); - - return new Promise((resolvePromise, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo; - boundPort = address.port; - resolvePromise({ - port: address.port, - close: () => new Promise((done) => server.close(() => done())), - }); - }); - }); -} - function status(port: number): Record { const paths = configPathsFor(packageRoot, dshHomeDir()); let pets: Array> = []; @@ -284,7 +194,9 @@ function landingPage(port: number): string { const info = status(port); const pets = (info.pets as Array>) ?? []; const petLines = pets.length - ? pets.map((pet) => `
  • ${escapeHtml(String(pet.name ?? pet.id))}(${escapeHtml(String(pet.display))})
  • `).join('') + ? pets + .map((pet) => `
  • ${escapeHtml(String(pet.name ?? pet.id))}(${escapeHtml(String(pet.display))})
  • `) + .join('') : '
  • (未读到宠物,检查配置文件)
  • '; return ` dsh-pet @@ -309,27 +221,6 @@ function landingPage(port: number): string { `; } -function sendText(res: ServerResponse, status: number, type: string, body: string): void { - res.writeHead(status, { - 'content-type': `${type}; charset=utf-8`, - 'content-length': Buffer.byteLength(body), - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - 'referrer-policy': 'no-referrer', - }); - res.end(body); -} - -function sendJson(res: ServerResponse, body: unknown): void { - const payload = JSON.stringify(body); - res.writeHead(200, { - 'content-type': 'application/json; charset=utf-8', - 'content-length': Buffer.byteLength(payload), - 'cache-control': 'no-store', - }); - res.end(payload); -} - function escapeHtml(value: string): string { return value.replace( /[&<>"']/g, diff --git a/dsh-pet/src/app/router.test.ts b/dsh-pet/src/app/router.test.ts new file mode 100644 index 00000000..d4f35794 --- /dev/null +++ b/dsh-pet/src/app/router.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { request as httpRequest } from 'node:http'; +import { test } from 'node:test'; +import type { RegisteredRoute } from '../standalone/context.ts'; +import { createRequestSecurity } from '../standalone/request-security.ts'; +import { MAX_REQUEST_BYTES, MAX_SETTINGS_BYTES } from '../standalone/request-body.ts'; +import { startRouter } from './router.ts'; + +async function start(routes: RegisteredRoute[] = [], log: (message: string) => void = () => {}) { + const security = createRequestSecurity(); + const server = await startRouter(routes, { + security, + log, + status: (port) => ({ audit: true, port }), + landingPage: () => 'local app', + }); + return { ...server, security, base: `http://127.0.0.1:${server.port}` }; +} + +test('app rejects untrusted file origins and hosts while authenticated desktop calls still work', async () => { + let calls = 0; + const server = await start([ + { + kind: 'prefix', + path: '/dsh-pet-7340', + handler: async (req, res) => { + calls++; + let body = ''; + await new Promise((resolve, reject) => { + req.on('data', (chunk: Buffer) => { + body += String(chunk); + }); + req.on('end', () => resolve()); + req.on('error', reject); + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ body })); + }, + }, + ]); + try { + const path = server.base + '/dsh-pet-7340/chat'; + const token = { 'X-DSH-Pet-Token': server.security.token }; + const blockedHeaders: Array> = [ + { Origin: 'null', 'Sec-Fetch-Site': 'cross-site' }, + { ...token, Origin: 'https://evil.test' }, + ]; + for (const method of ['GET', 'POST']) { + for (const headers of blockedHeaders) { + const response = await fetch(path, { method, headers }); + assert.equal(response.status, 403); + assert.equal(response.headers.get('access-control-allow-origin'), null); + await response.text(); + } + } + const wrongHost = await new Promise((resolve, reject) => { + const req = httpRequest(path, { headers: { ...token, Origin: 'null', Host: 'evil.test' } }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + req.end(); + }); + assert.equal(wrongHost, 403); + assert.equal(calls, 0); + const response = await fetch(path, { + method: 'POST', + headers: { ...token, Origin: 'null', 'Sec-Fetch-Site': 'cross-site', 'Content-Type': 'application/json' }, + body: '{"dummy":true}', + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), '*'); + assert.deepEqual(await response.json(), { body: '{"dummy":true}' }); + assert.equal(calls, 1); + const media = await fetch(server.base + '/dsh-pet-7340/thumb/main/test.webm', { + headers: { ...token, 'Sec-Fetch-Site': 'cross-site' }, + }); + assert.equal(media.status, 200); + await media.text(); + assert.equal(calls, 2); + // The file-origin exception does not apply to the AI settings endpoints. + assert.equal( + (await fetch(server.base + '/standalone-ai/settings', { headers: { ...token, Origin: 'null' } })).status, + 403, + ); + for (const encoded of ['%63hat', 'ch%61t']) { + assert.equal((await fetch(server.base + '/dsh-pet-7340/' + encoded)).status, 403); + } + assert.equal(calls, 2); + } finally { + await server.close(); + } +}); + +test('invalid URL and percent encodings return 400 and leave the app service available', async () => { + const server = await start(); + try { + const malformed = await new Promise((resolve, reject) => { + const req = httpRequest({ hostname: '127.0.0.1', port: server.port, path: '//[' }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + req.end(); + }); + assert.equal(malformed, 400); + assert.equal( + (await fetch(server.base + '/dsh-pet-7340/%ZZ', { headers: { 'X-DSH-Pet-Token': server.security.token } })) + .status, + 400, + ); + const health = await fetch(server.base + '/health'); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { audit: true, port: server.port }); + assert.equal(await (await fetch(server.base)).text(), 'local app'); + const shell = await fetch(server.base + '/standalone-ai/'); + assert.equal(shell.status, 200); + assert.ok(!(await shell.text()).includes(server.security.token)); + } finally { + await server.close(); + } +}); + +test('app caps request bodies before plugins/settings and catches synchronous and async handler failures', async () => { + let calls = 0; + const logs: string[] = []; + const server = await start( + [ + { + kind: 'prefix', + path: '/dsh-pet-7340', + handler: (req) => { + calls++; + if (req.url?.endsWith('/async')) return Promise.reject(new Error('PRIVATE_SENTINEL')); + throw new Error('PRIVATE_SENTINEL'); + }, + }, + ], + (message) => logs.push(message), + ); + try { + const headers = { 'X-DSH-Pet-Token': server.security.token }; + for (const [path, bytes] of [ + ['/dsh-pet-7340/config', MAX_REQUEST_BYTES], + ['/standalone-ai/settings', MAX_SETTINGS_BYTES], + ] as const) { + const response = await fetch(server.base + path, { method: 'POST', headers, body: 'x'.repeat(bytes + 1) }); + assert.equal(response.status, 413); + await response.text(); + } + assert.equal(calls, 0); + for (const path of ['/sync', '/async']) { + const response = await fetch(server.base + '/dsh-pet-7340' + path, { headers }); + assert.equal(response.status, 500); + assert.ok(!(await response.text()).includes('PRIVATE_SENTINEL')); + } + assert.equal(calls, 2); + assert.equal(logs.length, 2); + assert.ok(!logs.join('').includes('PRIVATE_SENTINEL')); + assert.equal((await fetch(server.base + '/health')).status, 200); + } finally { + await server.close(); + } +}); diff --git a/dsh-pet/src/app/router.ts b/dsh-pet/src/app/router.ts new file mode 100644 index 00000000..1d13a4ea --- /dev/null +++ b/dsh-pet/src/app/router.ts @@ -0,0 +1,124 @@ +import { createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { RegisteredRoute } from '../standalone/context'; +import { boundedRequest, MAX_REQUEST_BYTES, MAX_SETTINGS_BYTES } from '../standalone/request-body'; +import { rejectRequest, type RequestSecurity } from '../standalone/request-security'; +import { settingsHandler, testHandler, type StandaloneServerOptions } from '../standalone/server'; +import { settingsPage, settingsScript, settingsStyle } from '../standalone/settings-page'; + +interface RouterDeps { + security: RequestSecurity; + ai?: StandaloneServerOptions['ai']; + log(message: string): void; + status(port: number): unknown; + landingPage(port: number): string; +} + +/** App HTTP boundary, isolated from Electron so malformed and untrusted requests can be tested. */ +export function startRouter( + routes: RegisteredRoute[], + deps: RouterDeps, +): Promise<{ port: number; close: () => Promise }> { + const settings = settingsHandler(deps.ai); + const tester = testHandler(deps.ai); + let boundPort = 0; + + const server = createServer((req, res) => { + let url: URL; + try { + url = new URL(req.url ?? '/', 'http://127.0.0.1'); + } catch { + rejectRequest(res, 400, '请求地址无效'); + return; + } + const route = routes.find((candidate) => + candidate.kind === 'exact' + ? url.pathname === candidate.path + : url.pathname === candidate.path || url.pathname.startsWith(`${candidate.path}/`), + ); + // Only plugin calls may come from a file renderer, and even those require its capability. + if (!deps.security.authorize(req, res, !!route)) return; + const method = req.method ?? 'GET'; + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const dispatch = async (request: IncomingMessage): Promise => { + const assets: Record = { + '/standalone-ai/': [settingsPage, 'text/html'], + '/standalone-ai/settings.js': [settingsScript, 'text/javascript'], + '/standalone-ai/settings.css': [settingsStyle, 'text/css'], + }; + const asset = assets[url.pathname]; + if (asset || url.pathname === '/health' || url.pathname === '/') { + if (method !== 'GET' && method !== 'HEAD') { + rejectRequest(res, 405, '仅支持 GET'); + return; + } + if (asset) sendText(res, 200, asset[1], asset[0]); + else if (url.pathname === '/health') + sendText(res, 200, 'application/json', JSON.stringify(deps.status(boundPort))); + else sendText(res, 200, 'text/html', deps.landingPage(boundPort)); + return; + } + if (url.pathname === '/standalone-ai/settings' || url.pathname === '/standalone-ai/test') { + await (url.pathname === '/standalone-ai/test' ? tester : settings)(request, res); + return; + } + if (!route) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('not found'); + return; + } + // CORS is useful to the authenticated file renderer; it grants no capability by itself. + res.setHeader('access-control-allow-origin', '*'); + await route.handler(request, res); + }; + + const hasBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) > 0; + const bodyMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method); + void (async () => { + if (hasBody || bodyMethod) { + const limit = url.pathname === '/standalone-ai/settings' ? MAX_SETTINGS_BYTES : MAX_REQUEST_BYTES; + const bounded = await boundedRequest(req, res, limit); + if (bounded) await dispatch(bounded); + } else await dispatch(req); + })().catch(() => { + // Request bodies, URLs and thrown values may contain credentials; keep logs generic. + deps.log('本机接口处理失败'); + if (!res.headersSent) rejectRequest(res, 500, '接口暂时无法处理请求'); + else res.end(); + }); + }); + server.requestTimeout = 10000; + server.headersTimeout = 10000; + server.on('clientError', (_error, socket) => socket.destroy()); + + return new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + boundPort = (server.address() as AddressInfo).port; + resolvePromise({ + port: boundPort, + close: async () => { + server.closeAllConnections(); + await new Promise((done) => server.close(() => done())); + }, + }); + }); + }); +} + +function sendText(res: ServerResponse, status: number, type: string, body: string): void { + res.writeHead(status, { + 'content-type': `${type}; charset=utf-8`, + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + }); + res.end(body); +} diff --git a/dsh-pet/src/standalone/integration.test.ts b/dsh-pet/src/standalone/integration.test.ts index f71a652a..c004f04f 100644 --- a/dsh-pet/src/standalone/integration.test.ts +++ b/dsh-pet/src/standalone/integration.test.ts @@ -24,6 +24,7 @@ import { apply } from '../host/index.ts'; import { createStandaloneContext, type RegisteredRoute, type StandaloneLogger } from './context.ts'; import { configPathsFor } from './options.ts'; import { listenStandaloneServer, type StandaloneServer } from './server.ts'; +import { createRequestSecurity } from './request-security.ts'; /** 静默日志器(插件仍会往 console 打少量 warn,那是它自己的诊断,这里不拦) */ const silent: StandaloneLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; @@ -80,6 +81,30 @@ async function get(path: string): Promise<{ status: number; text: string; json: return { status: response.status, text, json: () => JSON.parse(text) as unknown }; } +test('real plugin chat routes cannot bypass the standalone capability using encoded names', async () => { + const security = createRequestSecurity(); + const protectedServer = await listenStandaloneServer({ + routes: context?.routes ?? [], + logger: silent, + port: 0, + security, + }); + try { + const prefix = `http://127.0.0.1:${protectedServer.port}/dsh-pet-7340/`; + for (const name of ['chat', '%63hat', 'ch%61t']) { + const response = await fetch(prefix + name + '?pet=main'); + assert.equal(response.status, 403); + await response.text(); + } + // With a valid capability the encoded name reaches the real plugin's parameter validation. + const response = await fetch(prefix + '%63hat', { headers: { 'X-DSH-Pet-Token': security.token } }); + assert.equal(response.status, 200); + assert.equal(((await response.json()) as { reason: string }).reason, 'pet-required'); + } finally { + await protectedServer.close(); + } +}); + describe('插件宿主半边在独立模式下跑起来了', () => { test('apply 注册了插件自己的路由前缀(独立模式不自己写路由表)', () => { const routes = context?.routes ?? []; diff --git a/dsh-pet/src/standalone/request-security.test.ts b/dsh-pet/src/standalone/request-security.test.ts index e0b742a8..59774898 100644 --- a/dsh-pet/src/standalone/request-security.test.ts +++ b/dsh-pet/src/standalone/request-security.test.ts @@ -32,6 +32,71 @@ test('capabilities are random, fragment-only, and process-bound', () => { assert.equal(new URLSearchParams(url.hash.slice(1)).get('token'), a.token); assert.throws(() => a.authenticatedUrl('https://evil.test/')); }); + +test('encoded private routes still require a capability; invalid encodings fail explicitly', () => { + const security = createRequestSecurity(); + for (const path of [ + '/dsh-pet-7340/%63hat?pet=main', + '/dsh-pet-7340/ch%61t', + '/dsh-pet-7340/chat%2Fhistory', + '/dsh-pet-7340/%77hisper', + ]) { + for (const method of ['GET', 'HEAD']) { + const res = response(); + assert.equal(security.authorize(request({}, path, method), res as unknown as ServerResponse), false); + assert.equal(res.status, 403); + assert.equal( + security.authorize( + request({ 'x-dsh-pet-token': security.token }, path, method), + response() as unknown as ServerResponse, + ), + true, + ); + } + } + for (const method of ['GET', 'POST']) { + const res = response(); + assert.equal( + security.authorize( + request({ 'x-dsh-pet-token': security.token }, '/dsh-pet-7340/%ZZ', method), + res as unknown as ServerResponse, + ), + false, + ); + assert.equal(res.status, 400); + } + assert.equal( + security.authorize(request({}, '/dsh-pet-7340/thumb/main/%E7%9C%8B.webm'), response() as unknown as ServerResponse), + true, + ); +}); + +test('desktop file-origin exception always needs a capability and never relaxes Host checks', () => { + const security = createRequestSecurity(); + const fileHeaders = { origin: 'null', 'sec-fetch-site': 'cross-site' }; + for (const path of ['/dsh-pet-7340/config', '/dsh-pet-7340/chat']) { + assert.equal(security.authorize(request(fileHeaders, path), response() as unknown as ServerResponse, true), false); + const headers = { ...fileHeaders, 'x-dsh-pet-token': security.token }; + assert.equal(security.authorize(request(headers, path), response() as unknown as ServerResponse, true), true); + assert.equal(security.authorize(request(headers, path), response() as unknown as ServerResponse), false); + assert.equal( + security.authorize( + request({ ...headers, host: 'evil.test:3080' }, path), + response() as unknown as ServerResponse, + true, + ), + false, + ); + assert.equal( + security.authorize( + request({ ...headers, origin: 'https://evil.test' }, path), + response() as unknown as ServerResponse, + true, + ), + false, + ); + } +}); test('all private reads and mutations need the header, never a query capability', () => { const security = createRequestSecurity(); for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']) { diff --git a/dsh-pet/src/standalone/request-security.ts b/dsh-pet/src/standalone/request-security.ts index 9764e692..60fdd299 100644 --- a/dsh-pet/src/standalone/request-security.ts +++ b/dsh-pet/src/standalone/request-security.ts @@ -5,7 +5,7 @@ export interface RequestSecurity { /** Process capability: private launcher manifest only, never an HTTP response or log. */ readonly token: string; authenticatedUrl(baseUrl: string): string; - authorize(req: IncomingMessage, res: ServerResponse): boolean; + authorize(req: IncomingMessage, res: ServerResponse, allowFileOrigin?: boolean): boolean; } export function rejectRequest(res: ServerResponse, status: number, error: string): false { @@ -15,13 +15,9 @@ export function rejectRequest(res: ServerResponse, status: number, error: string } function publicRead(req: IncomingMessage): boolean { + // The plugin decodes its route before dispatch; authorization must use the same spelling. + const path = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); if (req.method !== 'GET' && req.method !== 'HEAD') return false; - let path: string; - try { - path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; - } catch { - return false; - } if (['/standalone-ai/settings', '/shutdown'].includes(path)) return false; if (/^\/dsh-pet-\d+\/(?:whisper|chat)(?:\/|$)/.test(path)) return false; return ( @@ -47,20 +43,24 @@ export function createRequestSecurity(): RequestSecurity { } return `${base.origin}/standalone-ai/#token=${token}`; }, - authorize(req, res) { + authorize(req, res, allowFileOrigin = false) { const host = req.headers.host; const port = req.socket.localPort; if (!port || (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)) { return rejectRequest(res, 403, '仅允许当前本机地址'); } + // A file renderer still needs the process capability: sandboxed websites also send "null". + // Non-CORS media loads from the same file renderer may omit Origin altogether. + const fileOrigin = allowFileOrigin && (req.headers.origin === 'null' || req.headers.origin === undefined); if ( - (req.headers.origin !== undefined && req.headers.origin !== `http://${host}`) || - req.headers['sec-fetch-site'] === 'cross-site' + !fileOrigin && + ((req.headers.origin !== undefined && req.headers.origin !== `http://${host}`) || + req.headers['sec-fetch-site'] === 'cross-site') ) { return rejectRequest(res, 403, '不允许跨站请求'); } try { - if (publicRead(req)) return true; + if (publicRead(req) && !fileOrigin) return true; } catch { return rejectRequest(res, 400, '请求地址无效'); }