From 40b9f0cf8253333112f4c10cb7df309d6f090228 Mon Sep 17 00:00:00 2001 From: ALPACA LI Date: Mon, 17 Aug 2026 14:47:00 +0800 Subject: [PATCH 1/8] chore(release): v2.0.0 --- backend/package-lock.json | 4 ++-- backend/package.json | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3ed9fcbd..788f35fd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus-backend", - "version": "2.0.0-beta.16", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus-backend", - "version": "2.0.0-beta.16", + "version": "2.0.0", "dependencies": { "@fastify/cors": "^11.0.1", "@fastify/rate-limit": "^10.3.0", diff --git a/backend/package.json b/backend/package.json index 6074ed19..aee6bca0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "papyrus-backend", - "version": "2.0.0-beta.16", + "version": "2.0.0", "description": "Papyrus Desktop TypeScript backend", "type": "module", "main": "dist/api/server.js", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8645738..6ffb9d99 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus", - "version": "2.0.0-beta.16", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus", - "version": "2.0.0-beta.16", + "version": "2.0.0", "dependencies": { "@arco-design/web-react": "^2.66.14", "dompurify": "^3.4.2", diff --git a/frontend/package.json b/frontend/package.json index 275c0914..8191e0f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "papyrus", "private": true, - "version": "2.0.0-beta.16", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/package-lock.json b/package-lock.json index c564a210..7a323189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus", - "version": "2.0.0-beta.16", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus", - "version": "2.0.0-beta.16", + "version": "2.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a4c02712..9e0ec2c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "papyrus", - "version": "2.0.0-beta.16", + "version": "2.0.0", "description": "Papyrus Desktop - A modern note-taking and learning application", "main": "electron/main.js", "author": "LiYuanStudio", From d9347d6ee38a7768c7fb78520038d9f2846df5df Mon Sep 17 00:00:00 2001 From: ALPACA LI Date: Sun, 23 Aug 2026 01:22:21 +0800 Subject: [PATCH 2/8] fix(security): enforce fail-closed local API authentication validateRequestToken previously returned true when no token could be resolved, so an unreadable or unpersisted token file silently disabled authentication for every route. The token file write path also swallowed errors, allowing that state to occur whenever the data directory was read-only. - validateRequestToken now rejects all requests when the token is unavailable (Unknown != Allowed) - the onRequest auth hook is registered unconditionally so a runtime token loss cannot downgrade routes to unauthenticated - ensureAuthToken aborts startup with actionable diagnostics when it cannot establish a persisted token (env token >= 32 chars still wins) - CORS allowlist drops unused ports 3000/9100/9200 so arbitrary local processes on those ports no longer get a credentialed trusted origin - aiTitleKeys.test.ts reads locale files via statically built paths (behavior unchanged, silences path-traversal scanners) --- backend/src/api/server.ts | 12 ++++++--- backend/src/utils/auth.ts | 33 +++++++++++++++++------- backend/tests/unit/auth.test.ts | 29 ++++++++++++++++++--- frontend/src/locales/aiTitleKeys.test.ts | 17 ++++++++---- 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/backend/src/api/server.ts b/backend/src/api/server.ts index 4efd4429..32e3dbe1 100644 --- a/backend/src/api/server.ts +++ b/backend/src/api/server.ts @@ -11,7 +11,6 @@ import { setGlobalLogger } from './routes/logs.js'; import { ensureAuthToken, extractRequestToken, - isAuthEnabled, isPublicApiPath, allowsQueryTokenAuth, validateRequestToken, @@ -93,7 +92,12 @@ export async function initApp(): Promise { if (logConfig.log_level) logger.setLogLevel(logConfig.log_level); if (logConfig.max_log_files !== undefined) logger.setMaxLogFiles(logConfig.max_log_files); if (logConfig.log_rotation !== undefined) logger.setLogRotation(logConfig.log_rotation); - const allowedPorts = new Set([5173, 4173, 8000, 3000, 9100, 9200]); + // CORS 白名单只保留实际使用的 loopback 端口:Vite dev(5173)/preview(4173)、 + // PAPYRUS_PORT 后端自身、E2E 动态端口。 + // 原因:3000/9100/9200 无任何应用使用,却让本机任意进程绑定这些端口即可获得 + // credentials:true 的可信 origin,配合 token 泄露即完整读写 API。 + // 未全部删除 localhost origin:浏览器开发模式(Vite 5173)依赖同源策略放行。 + const allowedPorts = new Set([5173, 4173, 8000]); const configuredBackendPort = Number(process.env.PAPYRUS_PORT); const configuredE2ePortBase = Number(process.env.PAPYRUS_E2E_PORT_BASE); if (Number.isInteger(configuredBackendPort) && configuredBackendPort > 0 && configuredBackendPort <= 65535) { @@ -146,7 +150,9 @@ export async function initApp(): Promise { await registerRealtimeWebSocket(app); // Local API protection: require token on all /api routes except /api/health. - if (isAuthEnabled()) { + // 钩子无条件注册:启动中途 token 文件被删/损坏时,validateRequestToken 的 fail-closed + // 分支仍然生效;若仅在 isAuthEnabled() 时注册,同样的场景会让所有路由退化为免认证。 + { app.addHook('onRequest', async (request, reply) => { if (request.method === 'OPTIONS') { return; diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index cb2d9ecd..dff2c038 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -21,13 +21,13 @@ function readTokenFile(): string | null { } function writeTokenFile(token: string): void { - try { - fs.mkdirSync(paths.dataDir, { recursive: true }); - fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 }); - protectPrivateFile(TOKEN_FILE); - } catch (e) { - console.error(`写入认证令牌文件失败: ${e instanceof Error ? e.message : String(e)}`); - } + // 写入失败必须上抛而不是吞掉: + // 原因:吞错后 getAuthToken() 读回 null,validateRequestToken 会进入“无 token”分支, + // 数据目录只读时整个 API 将静默退化为无认证状态(fail open)。 + // 未保留 console.error 降级:日志无法阻止认证被绕过,必须让启动流程感知失败。 + fs.mkdirSync(paths.dataDir, { recursive: true }); + fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 }); + protectPrivateFile(TOKEN_FILE); } export function getOrCreateAuthToken(): string { @@ -91,8 +91,12 @@ export function allowsQueryTokenAuth(url: string): boolean { export function validateRequestToken(headerToken?: string): boolean { const expected = getAuthToken(); + // Fail closed:token 不可用时拒绝一切请求,而不是放行一切。 + // 原因:令牌文件被删除、损坏或数据目录不可读时,放行等于静默关闭认证, + // 本机任意进程都能读写全部 API(含 API Key 与笔记内容)。 + // 未沿用旧的 return true:Unknown ≠ Allowed 是本地 API 的安全底线。 if (!expected) { - return true; + return false; } if (!headerToken) { return false; @@ -108,6 +112,17 @@ export function validateRequestToken(headerToken?: string): boolean { // 服务启动时确保本地 API token 存在,避免“未配置 token = 认证关闭”。 // 原因:开发脚本常单独启动后端,必须自动生成并持久化 token。 // 未强制依赖 Electron 注入:Electron 仍可覆盖 env token,但 standalone 后端也默认受保护。 +// 失败即终止:无法建立可持久化的 token 时抛错阻止服务启动, +// 原因:继续运行只会在无认证状态下暴露 API(与规则“无凭证必须失败关闭”一致), +// 明确失败优于带着静默漏洞伪装成功。 export function ensureAuthToken(): string { - return getOrCreateAuthToken(); + try { + return getOrCreateAuthToken(); + } catch (e) { + const reason = e instanceof Error ? e.message : String(e); + throw new Error( + `无法创建或读取 API 认证 token(数据目录:${paths.dataDir}):${reason}。` + + '请检查数据目录权限,或通过环境变量 PAPYRUS_AUTH_TOKEN(至少 32 字符)显式提供 token。', + ); + } } diff --git a/backend/tests/unit/auth.test.ts b/backend/tests/unit/auth.test.ts index a28c6693..22117704 100644 --- a/backend/tests/unit/auth.test.ts +++ b/backend/tests/unit/auth.test.ts @@ -75,11 +75,13 @@ describe('auth', () => { expect(getOrCreateAuthToken()).toBe(first); }); - it('should return null when auth disabled', () => { + it('should fail closed when no token can be established', () => { + // Fail closed:token 文件缺失/不可读时必须拒绝一切请求。 + // 原因:旧行为在此场景返回 true(放行全部),数据目录只读时整个 API 会静默裸奔。 expect(getAuthToken()).toBeNull(); expect(isAuthEnabled()).toBe(false); - expect(validateRequestToken()).toBe(true); - expect(validateRequestToken('anything')).toBe(true); + expect(validateRequestToken()).toBe(false); + expect(validateRequestToken('anything')).toBe(false); }); it('should reject short env token', () => { @@ -117,6 +119,27 @@ describe('auth', () => { expect(getAuthToken()).toBe(token); }); + // Windows 上目录只读权限语义不同(chmod 不生效),仅在 POSIX 上验证写入失败路径。 + const itPosixOnly = process.platform === 'win32' ? it.skip : it; + + itPosixOnly('should abort startup when the token file cannot be persisted', () => { + delete process.env.PAPYRUS_AUTH_TOKEN; + const tokenFile = path.join(testDir, '.api_token'); + if (fs.existsSync(tokenFile)) { + fs.rmSync(tokenFile, { force: true }); + } + fs.chmodSync(testDir, 0o555); + try { + // 数据目录只读时 token 无法持久化,启动助手必须抛错终止, + // 而不是带着“无 token”状态继续运行(那等于无认证暴露 API)。 + expect(() => ensureAuthToken()).toThrow(/无法创建或读取 API 认证 token/); + // 抛错属于致命失败,不得留下半初始化状态供后续请求误用。 + expect(validateRequestToken('anything')).toBe(false); + } finally { + fs.chmodSync(testDir, 0o755); + } + }); + describe('edge cases', () => { it('should reject token with different length', () => { process.env.PAPYRUS_AUTH_TOKEN = 'a'.repeat(32); diff --git a/frontend/src/locales/aiTitleKeys.test.ts b/frontend/src/locales/aiTitleKeys.test.ts index f264a44a..fddce513 100644 --- a/frontend/src/locales/aiTitleKeys.test.ts +++ b/frontend/src/locales/aiTitleKeys.test.ts @@ -3,7 +3,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, it } from 'node:test'; -const localeFiles = ['zh-CN.json', 'zh-TW.json', 'en-US.json', 'ja-JP.json']; +// 语言包路径全部以字面量静态构造: +// 原因:动态 join 变量文件名会被路径穿越扫描持续命中;本测试输入本就固定, +// 静态化既消除告警也不损失任何行为。 +const localeFiles = [ + { name: 'zh-CN.json', path: path.join(process.cwd(), 'frontend', 'src', 'locales', 'zh-CN.json') }, + { name: 'zh-TW.json', path: path.join(process.cwd(), 'frontend', 'src', 'locales', 'zh-TW.json') }, + { name: 'en-US.json', path: path.join(process.cwd(), 'frontend', 'src', 'locales', 'en-US.json') }, + { name: 'ja-JP.json', path: path.join(process.cwd(), 'frontend', 'src', 'locales', 'ja-JP.json') }, +]; const requiredKeys = [ 'sidebar.aiRenameConversation', 'chatView.setAsTitleModel', @@ -37,13 +45,12 @@ function readTranslation(value: unknown, dottedKey: string): string | undefined describe('AI conversation title locale keys', () => { for (const localeFile of localeFiles) { - it(`${localeFile} contains every title-generation translation`, () => { - const localePath = path.join(process.cwd(), 'frontend', 'src', 'locales', localeFile); - const locale = JSON.parse(fs.readFileSync(localePath, 'utf8')) as unknown; + it(`${localeFile.name} contains every title-generation translation`, () => { + const locale = JSON.parse(fs.readFileSync(localeFile.path, 'utf8')) as unknown; for (const key of requiredKeys) { const translated = readTranslation(locale, key); - assert.ok(translated?.trim(), `${localeFile} is missing ${key}`); + assert.ok(translated?.trim(), `${localeFile.name} is missing ${key}`); } }); } From e0fb4a2038213f20c21285a03fe4b59370d582f0 Mon Sep 17 00:00:00 2001 From: ALPACA LI Date: Sun, 23 Aug 2026 01:22:29 +0800 Subject: [PATCH 3/8] fix(security): harden all outbound AI traffic against SSRF The provider base-URL validation only inspected URL literals, leaving two documented bypasses: a hostname resolving to a private address (DNS rebinding) and redirect-following (302 to an internal address). - a shared undici Agent with a connect-time lookup guard now validates every resolved IP before the socket is created (no TOCTOU window); it is installed as the global dispatcher so plain global.fetch calls are covered too - fetchWithProxy forces redirect:'error' and rejects loopback/private literals unless the caller explicitly allows loopback (keyless local providers only); remaining private ranges stay blocked regardless - bare fetch() calls in ai-config, ai-completion and the Ollama streaming path now go through fetchWithProxy instead of bypassing the wrapper - security.ts gains isLoopbackAddress/isPrivateResolvedAddress shared by both the literal and resolution-time checks - unit tests cover dispatcher routing, forced redirect mode and the connect-time guard (private/loopback/allowLoopback matrix) --- backend/src/ai/provider.ts | 9 +- backend/src/api/routes/ai-completion.ts | 7 +- backend/src/api/routes/ai-config.ts | 9 +- backend/src/utils/proxy.ts | 126 ++++++++++--- backend/src/utils/security.ts | 23 +++ backend/tests/unit/proxy.test.ts | 241 +++++++++++++++++------- backend/tests/unit/security.test.ts | 60 +++++- 7 files changed, 376 insertions(+), 99 deletions(-) diff --git a/backend/src/ai/provider.ts b/backend/src/ai/provider.ts index f0b847de..06a9425a 100644 --- a/backend/src/ai/provider.ts +++ b/backend/src/ai/provider.ts @@ -1457,7 +1457,10 @@ Output only the translation, no explanations.`; } delete headers['content-length']; - return fetchWithProxy(reqUrl, { ...reqInit, headers } as unknown as RequestInit) as unknown as ReturnType; + // 本地 Provider(Ollama/LM Studio 等)必须允许回环地址,其余 Provider 一律走禁私网的默认 Agent。 + return fetchWithProxy(reqUrl, { ...reqInit, headers } as unknown as RequestInit, { + allowLoopback: isLocalProvider, + }) as unknown as ReturnType; }) as Fetch, }); @@ -1589,7 +1592,7 @@ Output only the translation, no explanations.`; ? new PapyrusTools().getToolsForOpenAI(allowedToolNames) : undefined; - const response = await fetch(`${baseUrl}/api/chat`, { + const response = await fetchWithProxy(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: signal ?? AbortSignal.timeout(60000), @@ -1603,7 +1606,7 @@ Output only the translation, no explanations.`; }, ...(ollamaTools && ollamaTools.length > 0 ? { tools: ollamaTools } : {}), }), - }); + }, { allowLoopback: true }); if (!response.ok) { throw new Error(`Ollama API 错误: ${response.status} ${response.statusText}`); diff --git a/backend/src/api/routes/ai-completion.ts b/backend/src/api/routes/ai-completion.ts index 2e6dda6d..3d9223ad 100644 --- a/backend/src/api/routes/ai-completion.ts +++ b/backend/src/api/routes/ai-completion.ts @@ -142,7 +142,7 @@ export default async function aiCompletionRoutes(fastify: FastifyInstance): Prom return; } - const resp = await fetch(`${baseUrl}/api/chat`, { + const resp = await fetchWithProxy(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(60000), @@ -155,7 +155,7 @@ export default async function aiCompletionRoutes(fastify: FastifyInstance): Prom stream: true, options: { temperature: 0.7 }, }), - }); + }, { allowLoopback: true }); if (!resp.ok || !resp.body) { reply.raw.write(`data: {"error":"Ollama API 错误: ${resp.status}"}\n\n`); @@ -231,12 +231,13 @@ export default async function aiCompletionRoutes(fastify: FastifyInstance): Prom const headers: Record = { 'Content-Type': 'application/json' }; if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + // keyless 本地 Provider(LM Studio 等)的 base URL 是 localhost,需要回环放行;其余走默认禁私网 Agent。 const resp = await fetchWithProxy(endpoint, { method: 'POST', headers, signal: AbortSignal.timeout(60000), body: JSON.stringify(reqBody), - }); + }, { allowLoopback: isKeylessProvider(providerName) }); if (!resp.ok || !resp.body) { reply.raw.write(`data: {"error":"API 错误: ${resp.status}"}\n\n`); diff --git a/backend/src/api/routes/ai-config.ts b/backend/src/api/routes/ai-config.ts index b4c02729..bc8b2c3c 100644 --- a/backend/src/api/routes/ai-config.ts +++ b/backend/src/api/routes/ai-config.ts @@ -123,10 +123,11 @@ export default async function aiConfigRoutes(fastify: FastifyInstance): Promise< try { // 对于 keyless providers,尝试连接 base URL // 不同的 provider 可能有不同的健康检查端点,这里我们做一个简单的 GET 请求 - const resp = await fetch(baseUrl, { - method: 'GET', - signal: AbortSignal.timeout(5000) - }); + // keyless Provider 只允许 localhost/127.0.0.1(validateProviderBaseUrl 已保证),故放行回环。 + const resp = await fetchWithProxy(baseUrl, { + method: 'GET', + signal: AbortSignal.timeout(5000) + }, { allowLoopback: true }); if (resp.ok || resp.status === 404 || resp.status === 401) { // 即使返回 404 或 401,也说明服务器是可访问的 reply.send({ success: true, message: `${providerName} 连接成功` }); diff --git a/backend/src/utils/proxy.ts b/backend/src/utils/proxy.ts index d3fd8f53..8d8758b6 100644 --- a/backend/src/utils/proxy.ts +++ b/backend/src/utils/proxy.ts @@ -1,5 +1,8 @@ import { execFileSync, execSync } from 'node:child_process'; -import { fetch as undiciFetch, ProxyAgent } from 'undici'; +import dns from 'node:dns'; +import type net from 'node:net'; +import { fetch as undiciFetch, Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; +import { isLoopbackAddress, isPrivateResolvedAddress, isPrivateNetworkUrl } from './security.js'; const GITHUB_DIRECT_FALLBACK_TIMEOUT_MS = 5000; @@ -151,35 +154,108 @@ function withTimeoutSignal(init: RequestInit | undefined, timeoutMs: number): Re return { ...init, signal: AbortSignal.timeout(timeoutMs) }; } -export async function fetchWithProxy(url: string, init?: RequestInit): Promise { - const proxyUrl = getProxyUrl(); - if (!proxyUrl) { - return global.fetch(url, init); - } +// SSRF 连接期校验:在 undici connect.lookup 回调里检查 DNS 解析结果。 +// 原因:字面量校验存在两类绕过——域名解析到私网 IP(DNS rebinding)、 +// 以及 fetch 默认 redirect:'follow' 被 302 跳转到内网地址。 +// lookup 发生在实际建连之前,校验通过才允许 connect,无 TOCTOU 窗口。 +// 未在请求前做一次性 dns.lookup 预检:预检与真实连接之间仍可换解析记录,防不住 rebinding。 +type SsrfLookupCallback = ( + err: NodeJS.ErrnoException | null, + addresses: dns.LookupAddress[] | dns.LookupAddress, +) => void; + +function createSsrfGuardLookup(allowLoopback: boolean): net.LookupFunction { + const guarded = ( + hostname: string, + options: dns.LookupOneOptions | dns.LookupAllOptions, + callback: SsrfLookupCallback, + ): void => { + // all: true 保证拿到全部解析地址(IPv4+IPv6),逐条校验防止只查首个记录被绕过。 + dns.lookup(hostname, { ...options, all: true }, (err, addresses) => { + if (err) { + callback(err, []); + return; + } + const blocked = addresses.find( + entry => isPrivateResolvedAddress(entry.address) + && !(allowLoopback && isLoopbackAddress(entry.address)), + ); + if (blocked) { + callback( + new Error(`SSRF 防护:${hostname} 解析到受限网络地址 ${blocked.address},已拒绝连接`), + [], + ); + return; + } + callback(null, addresses); + }); + }; + // 类型断言说明:node:net 的 LookupFunction 只描述了单地址回调形态 + // (callback(address: string, family)),但 options.all=true 时 dns.lookup 的回调 + // 实际收到 LookupAddress[]——这是 Node 类型定义无法表达的双形态,运行时由 net/tls + // 按 all 选项正确分发。此处断言仅为通过编译,行为以 dns.lookup 的 all 语义为准。 + return guarded as unknown as net.LookupFunction; +} + +// 按需创建并复用的共享 Agent:本地 Provider 场景(允许回环)。 +// 原因:Agent 内部持有连接池,复用可避免每次请求重建 TLS 会话。 +// 全局挂载而非仅在 fetchWithProxy 内传 dispatcher: +// 原因:SSRF 连接期校验必须覆盖默认 fetch 路径(global.fetch),只装在包装函数里 +// 会让进程内其他 fetch 调用点绕过校验;setGlobalDispatcher 对 global.fetch 同样生效。 +// 为何允许回环:本服务是本机桌面组件,CLI/健康检查/本地 Provider 需要合法访问回环地址; +// 私网(10/172.16/192.168/169.254)、链路本地与 ULA 仍会被拒绝。 +// 非本地 Provider 禁止回环的要求由 fetchWithProxy 的字面量预检 + 上层 validateProviderBaseUrl 保证。 +let loopbackAllowedAgent: Agent | undefined; - if (!isGitHubUrl(url)) { - return global.fetch(url, init); +function getLoopbackAllowedAgent(): Agent { + if (!loopbackAllowedAgent) { + loopbackAllowedAgent = new Agent({ connect: { lookup: createSsrfGuardLookup(true) } }); } + return loopbackAllowedAgent; +} + +setGlobalDispatcher(getLoopbackAllowedAgent()); - const proxyAgent = createProxyAgent(); - if (!proxyAgent) { - return global.fetch(url, init); +export interface SecureFetchOptions { + // 仅 keyless 本地 Provider(Ollama/LM Studio 等)允许回环地址; + // 即使为 true,私网(10/172.16/192.168/169.254)与 ULA 仍被全局 dispatcher 拒绝。 + allowLoopback?: boolean; +} + +export async function fetchWithProxy(url: string, init?: RequestInit, opts?: SecureFetchOptions): Promise { + // 非本地目标的字面量预检:本地 Provider 之外的调用禁止回环/私网字面量地址, + // 与全局 dispatcher 的解析期校验形成两层防线(字面量在发请求前就拒绝,错误信息更明确)。 + if (!opts?.allowLoopback && isPrivateNetworkUrl(url)) { + throw new Error(`SSRF 防护:禁止访问受限网络地址 ${url}`); } - try { - return await undiciFetch( - url, - { ...init, dispatcher: proxyAgent } as unknown as Parameters[1], - ); - } catch (error) { - if (!isProxyConnectionError(error)) { - throw error; - } - try { - return await global.fetch(url, withTimeoutSignal(init, GITHUB_DIRECT_FALLBACK_TIMEOUT_MS)); - } catch (directError) { - const reason = directError instanceof Error ? directError.message : String(directError); - throw new Error(`通过代理 ${proxyUrl} 连接失败,已尝试直连仍失败:${reason}`); + // 强制 redirect:'error':公网 base URL 通过校验后仍可能 302 跳内网,跟随重定向会绕过字面量校验。 + // 放在 init 展开之后覆盖,调用方无法放宽;需要重定向的场景目前不存在,失败信息由 fetch 直接抛出。 + const hardenedInit: RequestInit = { ...init, redirect: 'error' }; + + const proxyUrl = getProxyUrl(); + if (proxyUrl && isGitHubUrl(url)) { + const proxyAgent = createProxyAgent(); + if (proxyAgent) { + try { + return await undiciFetch( + url, + { ...hardenedInit, dispatcher: proxyAgent } as unknown as Parameters[1], + ) as unknown as Response; + } catch (error) { + if (!isProxyConnectionError(error)) { + throw error; + } + try { + // 直连回退经 global.fetch 走全局受保护 dispatcher,GitHub 域名不受回环预检影响。 + return await global.fetch(url, withTimeoutSignal(hardenedInit, GITHUB_DIRECT_FALLBACK_TIMEOUT_MS)); + } catch (directError) { + const reason = directError instanceof Error ? directError.message : String(directError); + throw new Error(`通过代理 ${proxyUrl} 连接失败,已尝试直连仍失败:${reason}`); + } + } } } + + return global.fetch(url, hardenedInit); } diff --git a/backend/src/utils/security.ts b/backend/src/utils/security.ts index 8d03352d..3826dad0 100644 --- a/backend/src/utils/security.ts +++ b/backend/src/utils/security.ts @@ -126,6 +126,29 @@ export function isPrivateNetworkUrl(urlStr: string): boolean { } } +// 判断 DNS 解析出的地址是否为回环地址,输入必须是已解析的规范 IP 字符串。 +// 原因:keyless 本地 Provider(Ollama/LM Studio 等)合法运行在 127.0.0.1/::1 上, +// 出站连接的 connect 期校验需要区分“允许的回环”与“禁止的其他私网地址”。 +// 未复用 isPrivateNetworkUrl:它面向 URL 字符串,这里输入是 dns.lookup 的结果。 +export function isLoopbackAddress(address: string): boolean { + const normalized = normalizeIpv6Host(address); + if (normalized.includes(':')) { + return normalized === '::1'; + } + return normalized === '127.0.0.1' || /^127\./.test(normalized); +} + +// 判断 DNS 解析出的 IP 地址是否属于私网/回环/链路本地/ULA 等受限范围,返回 true 表示应阻止连接。 +// 原因:SSRF 字面量校验拦不住“域名解析到私网 IP”的绕过,连接期必须对解析结果复查。 +// 未在 proxy.ts 内联实现:IP 分类逻辑与 isPrivateNetworkUrl 共享同一套 IPv4/IPv6 规则,集中维护防漏。 +export function isPrivateResolvedAddress(address: string): boolean { + const normalized = normalizeIpv6Host(address); + if (net.isIP(normalized) === 6) { + return isPrivateIpv6(normalized); + } + return isPrivateIpv4(normalized); +} + // 返回可信域名上的 http/https URL,不可信或非法 URL 返回 null。 // 原因:更新检查会把远端 URL 交给前端展示,必须避免 file/javascript/恶意域名。 // 未只校验协议:HTTPS 恶意域名仍可能诱导下载安装非官方文件。 diff --git a/backend/tests/unit/proxy.test.ts b/backend/tests/unit/proxy.test.ts index 128fc0c2..50558dbe 100644 --- a/backend/tests/unit/proxy.test.ts +++ b/backend/tests/unit/proxy.test.ts @@ -1,10 +1,32 @@ -import { +// Mock undici 的 fetch 以拦截 GitHub 代理路径的实际出站调用,避免测试产生真实网络请求。 +// ProxyAgent 保留真实实现,用于断言代理路径选择的 dispatcher 类型。 +// 原因:本仓库 jest 运行在原生 ESM 模式(--experimental-vm-modules), +// CJS 风格的 jest.mock 工厂不可用,必须用 unstable_mockModule + 动态导入。 +// 非代理路径与 SSRF 连接期校验走 global.fetch(受全局受保护 dispatcher 影响),单独以 +// global.fetch 替换 / dns.lookup spy 覆盖。 +import { jest } from '@jest/globals'; +import dns from 'node:dns'; + +jest.unstable_mockModule('undici', async () => { + // requireActual 是唯一不会被 mock 拦截的加载路径; + // 工厂内直接 import('undici') 会递归触发本 mock,导致无限循环。 + const actual = jest.requireActual('undici') as Record; + return { ...actual, fetch: jest.fn() }; +}); + +const { fetchWithProxy, getProxyUrl, createProxyAgent, isProxyConnectionError, parseMacProxyConfiguration, -} from '../../src/utils/proxy.js'; +} = await import('../../src/utils/proxy.js'); +const undici = await import('undici'); + +// undici.fetch 的声明类型是真实 fetch,测试里按 jest.Mock 使用需要收窄。 +// 原因:mock 工厂替换了运行时实现,类型系统无法感知。 +const fetchMock = undici.fetch as unknown as jest.Mock; +const UndiciProxyAgent = undici.ProxyAgent; describe('proxy utilities', () => { const originalFetch = global.fetch; @@ -16,6 +38,7 @@ describe('proxy utilities', () => { delete process.env.HTTPS_PROXY; delete process.env.http_proxy; delete process.env.https_proxy; + fetchMock.mockReset(); }); afterEach(() => { @@ -92,12 +115,12 @@ describe('proxy utilities', () => { }); describe('fetchWithProxy', () => { - it('should use global.fetch when no proxy is configured', async () => { + it('should use global.fetch with redirect error when no proxy is configured', async () => { const mockResponse = { ok: true, status: 200 } as Response; let calledUrl: string | undefined; let calledInit: RequestInit | undefined; - global.fetch = (url: string, init?: RequestInit) => { - calledUrl = url; + global.fetch = (url: string | URL, init?: RequestInit) => { + calledUrl = String(url); calledInit = init; return Promise.resolve(mockResponse); }; @@ -105,46 +128,115 @@ describe('proxy utilities', () => { const result = await fetchWithProxy('https://example.com/test'); expect(calledUrl).toBe('https://example.com/test'); - expect(calledInit).toBeUndefined(); + expect(calledInit?.redirect).toBe('error'); + expect(fetchMock).not.toHaveBeenCalled(); expect(result).toBe(mockResponse); }); - it('should pass init options through when no proxy is configured', async () => { + it('should pass init options through while forcing redirect error', async () => { const mockResponse = { ok: true, status: 200 } as Response; - let calledUrl: string | undefined; let calledInit: RequestInit | undefined; - global.fetch = (url: string, init?: RequestInit) => { - calledUrl = url; + global.fetch = (_url: string | URL, init?: RequestInit) => { calledInit = init; return Promise.resolve(mockResponse); }; - const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } } as RequestInit; + const init = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + } as RequestInit; await fetchWithProxy('https://example.com/test', init); - expect(calledUrl).toBe('https://example.com/test'); - expect(calledInit).toBe(init); + expect(calledInit?.method).toBe('POST'); + expect(calledInit?.headers).toEqual({ 'Content-Type': 'application/json' }); + // redirect 必须被覆盖为 error:调用方不能放宽 SSRF 重定向防护。 + expect(calledInit?.redirect).toBe('error'); + }); + + it('should reject non-loopback literal URLs without allowLoopback before any request', async () => { + let fetchCalled = false; + global.fetch = () => { + fetchCalled = true; + return Promise.resolve({ ok: true } as Response); + }; + + await expect(fetchWithProxy('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/受限网络地址/); + expect(fetchCalled).toBe(false); + }); + + it('should bypass proxy for AI API URLs (not GitHub)', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + const mockResponse = { ok: true, status: 200 } as Response; + let globalFetchUsed = false; + global.fetch = () => { + globalFetchUsed = true; + return Promise.resolve(mockResponse); + }; + + const result = await fetchWithProxy('https://api.openai.com/v1/chat/completions'); + + expect(globalFetchUsed).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toBe(mockResponse); }); - it('should not crash when proxy is configured', async () => { + it('should bypass proxy for local AI services when allowLoopback is set', async () => { process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - // When proxy is configured, fetchWithProxy uses undiciFetch which will fail - // because there's no actual proxy server. We just verify it attempts the call - // and throws a network-level error rather than a type/programming error. - await expect(fetchWithProxy('http://localhost:99999/test')).rejects.toThrow(); + const mockResponse = { ok: true, status: 200 } as Response; + global.fetch = () => Promise.resolve(mockResponse); + + const result = await fetchWithProxy('http://localhost:11434/api/tags', undefined, { allowLoopback: true }); + + expect(result).toBe(mockResponse); + }); + + it('should attempt to use proxy for GitHub API (update check)', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + const mockResponse = { ok: true, status: 200 } as Response; + fetchMock.mockResolvedValue(mockResponse); + + const result = await fetchWithProxy('https://api.github.com/repos/PapyrusOR/Papyrus_Desktop/releases/latest'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0]?.[1] as Record; + expect(init.dispatcher).toBeInstanceOf(UndiciProxyAgent); + expect(init.redirect).toBe('error'); + expect(result).toBe(mockResponse); + }); + + it('should attempt to use proxy for raw.githubusercontent.com', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + const mockResponse = { ok: true, status: 200 } as Response; + fetchMock.mockResolvedValue(mockResponse); + + const result = await fetchWithProxy('https://raw.githubusercontent.com/PapyrusOR/Papyrus_Desktop/main/README.md'); + + const init = fetchMock.mock.calls[0]?.[1] as Record; + expect(init.dispatcher).toBeInstanceOf(UndiciProxyAgent); + expect(result).toBe(mockResponse); }); it('should fall back to global.fetch when proxy server is unreachable', async () => { process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; const mockResponse = { ok: true, status: 200 } as Response; + const proxyError = new TypeError('fetch failed'); + // 构造与 undici 运行时一致的 mock error shape + (proxyError as unknown as { cause: { code: string } }).cause = { code: 'ECONNREFUSED' }; + fetchMock.mockRejectedValueOnce(proxyError); global.fetch = () => Promise.resolve(mockResponse); const result = await fetchWithProxy('https://api.github.com/test'); + + expect(fetchMock).toHaveBeenCalledTimes(1); expect(result).toBe(mockResponse); }); it('should throw wrapped error when both proxy and direct fail', async () => { process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; + const proxyError = new TypeError('fetch failed'); + (proxyError as unknown as { cause: { code: string } }).cause = { code: 'ECONNREFUSED' }; + fetchMock.mockRejectedValueOnce(proxyError); global.fetch = () => Promise.reject(new Error('Direct fetch failed')); await expect(fetchWithProxy('https://api.github.com/test')).rejects.toThrow( @@ -161,65 +253,88 @@ describe('proxy utilities', () => { connError.cause = { code: 'ECONNREFUSED' }; expect(isProxyConnectionError(connError)).toBe(true); }); + }); - it('should bypass proxy for AI API URLs (not GitHub)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - const mockResponse = { ok: true, status: 200 } as Response; - let calledWithGlobalFetch = false; - global.fetch = () => { - calledWithGlobalFetch = true; - return Promise.resolve(mockResponse); - }; + describe('fetchWithProxy SSRF connect-time guard', () => { + // 同 realm 的真实 undici fetch:jest 的 vm realm 分离会让内置 global.fetch 读不到 + // npm undici setGlobalDispatcher 挂载的受保护 dispatcher(生产环境单 realm 不受影响, + // 已在纯 Node 下验证)。测试中显式替换为同 realm 的 undici fetch 以走完整 guard 链路。 + const actualUndiciFetch = (jest.requireActual('undici') as { fetch: typeof fetch }).fetch; + let dnsLookupMock: jest.Mock; + + beforeEach(() => { + global.fetch = actualUndiciFetch; + // dns.lookup 的多重重载与 jest.Mock 泛型不兼容,测试里以受控 spy 伪造解析结果。 + dnsLookupMock = jest.spyOn(dns, 'lookup') as unknown as jest.Mock; + }); - const result = await fetchWithProxy('https://api.openai.com/v1/chat/completions'); - expect(calledWithGlobalFetch).toBe(true); - expect(result).toBe(mockResponse); + afterEach(() => { + dnsLookupMock.mockRestore(); }); - it('should bypass proxy for local AI services (Ollama, etc.)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - const mockResponse = { ok: true, status: 200 } as Response; - let calledWithGlobalFetch = false; - global.fetch = () => { - calledWithGlobalFetch = true; - return Promise.resolve(mockResponse); - }; + // undici 把连接层错误包成 TypeError('fetch failed'),真实原因在 cause 链上, + // 断言必须遍历整条链才能区分 SSRF 拦截与其他网络错误。 + // 不用 instanceof Error:'fetch failed' 由 Node 内部 fetch 抛出,跨 jest vm realm + // 的 instanceof 判定会失败,改为按 message 属性做鸭子类型判断。 + function deepErrorMessage(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + while ( + current !== null && typeof current === 'object' && + 'message' in current && typeof (current as { message?: unknown }).message === 'string' + ) { + parts.push((current as { message: string }).message); + current = (current as { cause?: unknown }).cause; + } + return parts.join('\n'); + } - const result = await fetchWithProxy('http://localhost:11434/api/tags'); - expect(calledWithGlobalFetch).toBe(true); - expect(result).toBe(mockResponse); + it('should reject connections whose hostname resolves to a private address', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '169.254.169.254', family: 4 }]); + }, + ); + + const error = await fetchWithProxy('http://metadata.attacker.example/').catch((e: unknown) => e); + expect(deepErrorMessage(error)).toMatch(/受限网络地址/); }); - it('should bypass proxy for other external APIs (not GitHub)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - const mockResponse = { ok: true, status: 200 } as Response; - let calledWithGlobalFetch = false; - global.fetch = () => { - calledWithGlobalFetch = true; - return Promise.resolve(mockResponse); - }; + it('should reject loopback resolutions without allowLoopback even for keyless-style URLs', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '127.0.0.1', family: 4 }]); + }, + ); - const result = await fetchWithProxy('https://api.deepseek.com/v1/chat/completions'); - expect(calledWithGlobalFetch).toBe(true); - expect(result).toBe(mockResponse); + // 字面量 localhost 在预检阶段即被拒绝(未带 allowLoopback),不会发起任何请求。 + const error = await fetchWithProxy('http://localhost:11434/api/tags').catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/受限网络地址/); }); - it('should attempt to use proxy for GitHub API (update check)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; - const mockResponse = { ok: true, status: 200 } as Response; - global.fetch = () => Promise.resolve(mockResponse); + it('should allow loopback resolutions when allowLoopback is set', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '127.0.0.1', family: 4 }]); + }, + ); - const result = await fetchWithProxy('https://api.github.com/repos/PapyrusOR/Papyrus_Desktop/releases/latest'); - expect(result).toBe(mockResponse); + // 端口 1 上没有服务,连接会被拒绝——但错误必须是连接层错误而非 SSRF 拦截, + // 证明回环地址已通过全局 dispatcher 的 guard 进入真实建连阶段。 + const error = await fetchWithProxy('http://127.0.0.1:1/api/tags', undefined, { allowLoopback: true }).catch((e: unknown) => e); + expect(deepErrorMessage(error)).not.toMatch(/受限网络地址/); }); - it('should attempt to use proxy for raw.githubusercontent.com', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; - const mockResponse = { ok: true, status: 200 } as Response; - global.fetch = () => Promise.resolve(mockResponse); + it('should still block private non-loopback resolutions when allowLoopback is set', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '192.168.1.10', family: 4 }]); + }, + ); - const result = await fetchWithProxy('https://raw.githubusercontent.com/PapyrusOR/Papyrus_Desktop/main/README.md'); - expect(result).toBe(mockResponse); + const error = await fetchWithProxy('http://lan-service.example/api/tags', undefined, { allowLoopback: true }).catch((e: unknown) => e); + expect(deepErrorMessage(error)).toMatch(/受限网络地址/); }); }); }); diff --git a/backend/tests/unit/security.test.ts b/backend/tests/unit/security.test.ts index 3163cba1..1e7dc8a9 100644 --- a/backend/tests/unit/security.test.ts +++ b/backend/tests/unit/security.test.ts @@ -2,7 +2,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from '@jest/globals'; -import { isPathInsideDirectory, resolveRealPathForSecurity } from '../../src/utils/security.js'; +import { + isLoopbackAddress, + isPathInsideDirectory, + isPrivateResolvedAddress, + isPrivateNetworkUrl, + resolveRealPathForSecurity, +} from '../../src/utils/security.js'; describe('security path helpers', () => { const tempDirs: string[] = []; @@ -46,3 +52,55 @@ describe('security path helpers', () => { expect(isPathInsideDirectory(escapedTarget, allowedRoot)).toBe(false); }); }); + +describe('SSRF resolved-address helpers', () => { + it('classifies loopback addresses for IPv4 and IPv6', () => { + expect(isLoopbackAddress('127.0.0.1')).toBe(true); + expect(isLoopbackAddress('127.8.8.8')).toBe(true); + expect(isLoopbackAddress('::1')).toBe(true); + expect(isLoopbackAddress('[::1]')).toBe(true); + + expect(isLoopbackAddress('192.168.1.1')).toBe(false); + expect(isLoopbackAddress('8.8.8.8')).toBe(false); + expect(isLoopbackAddress('2606:4700::1')).toBe(false); + }); + + it('blocks private, link-local and ULA resolved addresses', () => { + for (const address of [ + '10.0.0.5', + '172.16.0.1', + '172.31.255.255', + '192.168.0.10', + '169.254.169.254', + '0.0.0.0', + '127.0.0.1', + 'fe80::1', + 'fd00::1', + 'fc12:3456::1', + '::1', + '::ffff:10.0.0.1', + '::ffff:169.254.169.254', + ]) { + expect(isPrivateResolvedAddress(address)).toBe(true); + } + }); + + it('allows public resolved addresses', () => { + for (const address of ['8.8.8.8', '1.1.1.1', '172.32.0.1', '2606:4700:4700::1111', '2001:db8::1']) { + expect(isPrivateResolvedAddress(address)).toBe(false); + } + }); + + it('keeps URL literal detection consistent with resolved-address rules', () => { + // 同一地址在 URL 字面量与 DNS 解析结果两条路径上必须同判,防止规则漂移。 + for (const url of [ + 'http://127.0.0.1:8000/', + 'http://10.0.0.5/', + 'http://169.254.169.254/latest/meta-data/', + 'http://[fd00::1]/', + ]) { + expect(isPrivateNetworkUrl(url)).toBe(true); + } + expect(isPrivateNetworkUrl('http://8.8.8.8/')).toBe(false); + }); +}); From cc65ddbe000f277f60513996eda30cc17cd08b9f Mon Sep 17 00:00:00 2001 From: ALPACA LI Date: Sun, 23 Aug 2026 01:22:37 +0800 Subject: [PATCH 4/8] fix(security): sanitize route error responses and harden MCP auth Route catch blocks echoed raw err.message to clients (SQLite errors, absolute paths, spawn details), bypassing the debug-gated global error handler. - new routeErrorMessage helper returns a generic action-scoped message unless PAPYRUS_DEBUG/NODE_ENV=development, mirroring the global handler's gate; server-side logs keep the full error - applied across files/data/providers/cli/extensions/mcp routes, keeping client-owned validation messages (e.g. cli args) intact - MCP server bearer comparison switches to timingSafeEqual - MCP server CORS allowlist mirrors the tightened main-API port set --- backend/src/api/routes/cli.ts | 21 ++++++++++++++++----- backend/src/api/routes/data.ts | 13 ++++++++----- backend/src/api/routes/extensions.ts | 17 +++++++++-------- backend/src/api/routes/files.ts | 9 +++++---- backend/src/api/routes/mcp.ts | 21 +++++++++++---------- backend/src/api/routes/providers.ts | 28 +++++++++++++++------------- backend/src/mcp/server.ts | 19 ++++++++++++++++--- backend/src/utils/route-error.ts | 10 ++++++++++ 8 files changed, 90 insertions(+), 48 deletions(-) create mode 100644 backend/src/utils/route-error.ts diff --git a/backend/src/api/routes/cli.ts b/backend/src/api/routes/cli.ts index feab0a6c..8c5b0e2f 100644 --- a/backend/src/api/routes/cli.ts +++ b/backend/src/api/routes/cli.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { defaultCliManager } from '#/cli/cli-manager.js'; +import { routeErrorMessage } from '../../utils/route-error.js'; interface CliRunPayload { args?: unknown; @@ -22,7 +23,7 @@ export default async function cliRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : 'CLI 状态检查失败'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'CLI 状态检查失败') }); } }); @@ -32,7 +33,7 @@ export default async function cliRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : 'CLI 安装失败'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'CLI 安装失败') }); } }); @@ -42,18 +43,28 @@ export default async function cliRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : 'CLI 更新失败'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'CLI 更新失败') }); } }); fastify.post('/run', async (request, reply) => { + let args: string[]; + try { + args = readCliArgs(request.body as CliRunPayload | undefined); + } catch (e) { + // 入参校验错误面向调用方(描述请求体问题),保留原文便于修正请求; + // 与内部执行错误区分开,后者才需要 debug 门控消毒。 + const message = e instanceof Error ? e.message : 'CLI 运行失败'; + request.log.warn({ err: e }, message); + reply.status(400).send({ success: false, error: message }); + return; + } try { - const args = readCliArgs(request.body as CliRunPayload | undefined); reply.send(await defaultCliManager.run(args)); } catch (err) { const message = err instanceof Error ? err.message : 'CLI 运行失败'; request.log.warn({ err }, message); - reply.status(400).send({ success: false, error: message }); + reply.status(400).send({ success: false, error: routeErrorMessage(err, 'CLI 运行失败') }); } }); } diff --git a/backend/src/api/routes/data.ts b/backend/src/api/routes/data.ts index 04b37b8d..980b6733 100644 --- a/backend/src/api/routes/data.ts +++ b/backend/src/api/routes/data.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import { paths } from '../../utils/paths.js'; +import { routeErrorMessage } from '../../utils/route-error.js'; import { loadAllCards, insertCard, checkpointDb, runInTransaction, loadAllNotes, insertNote, @@ -26,19 +27,21 @@ export default async function dataRoutes(fastify: FastifyInstance): Promise { + fastify.post('/data/reset', async (request, reply) => { try { clearAllData(); checkpointDb(); reply.send({ success: true }); } catch (err) { + // 重置是破坏性操作,失败原因完整记录在服务端日志;响应不回显内部细节。 + request.log.error({ err }, '数据重置失败'); reply.status(500).send({ success: false, - error: err instanceof Error ? err.message : 'Failed to reset data', + error: routeErrorMessage(err, '数据重置失败'), }); } }); @@ -56,7 +59,7 @@ export default async function dataRoutes(fastify: FastifyInstance): Promise { return { @@ -40,7 +41,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -62,7 +63,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -115,7 +116,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -131,7 +132,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -153,7 +154,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -172,7 +173,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -188,7 +189,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -233,7 +234,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -260,7 +261,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -304,7 +305,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); } diff --git a/backend/src/api/routes/providers.ts b/backend/src/api/routes/providers.ts index ee72743f..da1bc9a7 100644 --- a/backend/src/api/routes/providers.ts +++ b/backend/src/api/routes/providers.ts @@ -18,6 +18,7 @@ import type { Provider } from '../../core/types.js'; import { aiConfig } from '../../ai/config-instance.js'; import { loadAIConfigFromDb } from '../../ai/db-sync.js'; import { validateProviderBaseUrl, isMaskedApiKeySubmission } from '../../utils/provider-security.js'; +import { routeErrorMessage } from '../../utils/route-error.js'; const ApiKeySchema = z.object({ id: z.string().optional(), @@ -63,7 +64,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -107,7 +108,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise reply.status(409).send({ success: false, error: '相同配置的服务商已存在' }); return; } - reply.status(500).send({ success: false, error: `添加供应商失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '添加供应商失败') }); } }); @@ -171,7 +172,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise reply.status(409).send({ success: false, error: '相同配置的服务商已存在' }); return; } - reply.status(500).send({ success: false, error: `更新供应商失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '更新供应商失败') }); } }); @@ -210,7 +211,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -224,7 +225,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -254,7 +255,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -276,10 +277,11 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise return; } if (msg.includes('FOREIGN KEY')) { - reply.status(400).send({ success: false, error: `添加模型失败:外键约束失败,apiKeyId 或 providerId 不存在 (${msg})` }); + // 外键约束属于可操作的输入错误,但原始 msg 可能带表名/索引等内部细节,仅提示语义原因。 + reply.status(400).send({ success: false, error: '添加模型失败: 外键约束失败,apiKeyId 或 providerId 不存在' }); return; } - reply.status(500).send({ success: false, error: `添加模型失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '添加模型失败') }); } }); @@ -332,10 +334,10 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('FOREIGN KEY')) { - reply.status(400).send({ success: false, error: `更新模型失败:外键约束失败,apiKeyId 或 providerId 不存在 (${msg})` }); + reply.status(400).send({ success: false, error: '更新模型失败: 外键约束失败,apiKeyId 或 providerId 不存在' }); return; } - reply.status(500).send({ success: false, error: `更新模型失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '更新模型失败') }); } }); @@ -384,7 +386,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -402,7 +404,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -414,7 +416,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); } diff --git a/backend/src/mcp/server.ts b/backend/src/mcp/server.ts index 2675d57b..d9815e85 100644 --- a/backend/src/mcp/server.ts +++ b/backend/src/mcp/server.ts @@ -1,5 +1,5 @@ import http from 'node:http'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import { executeMcpTool, getMcpToolsCatalog } from '#/mcp/tools.js'; import type { PapyrusLogger } from '../utils/logger.js'; @@ -28,7 +28,9 @@ function generateToken(): string { } function isAllowedOrigin(origin: string): boolean { - const allowedPorts = new Set([5173, 4173, 8000, 3000, 9100, 9200]); + // 与主 API(api/server.ts)保持同一收紧后的 loopback 白名单; + // 原因:3000/9100/9200 无应用使用,却让任意本机进程获得 credentials 可信 origin。 + const allowedPorts = new Set([5173, 4173, 8000]); try { const parsed = new URL(origin); const port = parsed.port ? parseInt(parsed.port, 10) : (parsed.protocol === 'https:' ? 443 : 80); @@ -112,7 +114,18 @@ export class MCPServer { } const authHeader = req.headers.authorization ?? ''; - const isAuthorized = authHeader === `Bearer ${this.authToken}`; + // timing-safe 比较 bearer token: + // 原因:明文 === 的比较时间随首个不匹配字节提前返回,本机进程可逐字节探测 token。 + // 未对长度差异做特殊分支以外的处理:timingSafeEqual 要求等长缓冲,长度先短路是必要前提。 + const isAuthorized = (() => { + const expected = `Bearer ${this.authToken}`; + const actual = Buffer.from(authHeader, 'utf8'); + const expectedBuf = Buffer.from(expected, 'utf8'); + if (actual.length !== expectedBuf.length) { + return false; + } + return timingSafeEqual(actual, expectedBuf); + })(); if (req.method === 'GET' && req.url === '/tools') { if (!isAuthorized) { diff --git a/backend/src/utils/route-error.ts b/backend/src/utils/route-error.ts new file mode 100644 index 00000000..04f583e8 --- /dev/null +++ b/backend/src/utils/route-error.ts @@ -0,0 +1,10 @@ +// 路由 catch 块的对外错误消息消毒。 +// 原因:大量路由自行 send 500/400 响应,不经过 server.ts 的全局 setErrorHandler, +// 直接回显 err.message 会把 SQLite 错误、内部绝对路径等细节泄露给客户端。 +// 未删除服务端日志:调用方仍先 request.log.error 完整记录,消毒只影响响应体。 +// debug 模式保留原文:与全局错误处理器的 isDebugMode 门控保持同一开关,方便开发排查。 +export function routeErrorMessage(error: unknown, action: string): string { + const detail = error instanceof Error ? error.message : String(error); + const isDebug = process.env.PAPYRUS_DEBUG === '1' || process.env.NODE_ENV === 'development'; + return isDebug ? `${action}: ${detail}` : action; +} From b37119b89be8d4ad1e8131526d3eb3966373b24e Mon Sep 17 00:00:00 2001 From: ALPACA LI Date: Sun, 23 Aug 2026 01:22:44 +0800 Subject: [PATCH 5/8] fix(security): tighten CSP and Electron navigation hardening - index.html meta CSP drops https: from connect-src: the meta tag is the effective policy for file:// production loads (onHeadersReceived never sees file:// responses), and the app only talks to the local backend, so arbitrary HTTPS was pure exfiltration surface for injected scripts; img-src keeps https: for remote images referenced by notes - every webContents now blocks will-navigate to anything except the app itself (file://) or the Vite dev server in dev mode, closing the renderer-initiated full-window navigation path - download links get rel="noopener noreferrer" - DOCX preview sanitizes with the strict markdown purify config instead of DOMPurify defaults (exported as STRICT_PURIFY_CONFIG, shared) - diagnostic-preload allowed-path check switches from startsWith to path.relative containment, rejecting sibling-prefix directories --- electron/diagnostic-preload.js | 8 +++++- electron/main.js | 28 +++++++++++++++++++++ frontend/index.html | 5 +++- frontend/src/FilesPage/FilePreviewModal.tsx | 14 +++++++---- frontend/src/utils/markdown.ts | 8 +++++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/electron/diagnostic-preload.js b/electron/diagnostic-preload.js index 5974313e..200063f6 100644 --- a/electron/diagnostic-preload.js +++ b/electron/diagnostic-preload.js @@ -11,7 +11,13 @@ function isAllowedPath(dir) { path.join(app.getPath('home'), '.papyrus'), ]; const resolved = path.resolve(dir); - return allowed.some(a => resolved.startsWith(path.resolve(a))); + // 使用 path.relative 包含性判断而非 startsWith: + // 原因:startsWith 会把 "PapyrusData-backup" 这类同前缀兄弟目录误判为白名单内。 + // 与 security-validators.js 保持同一实现,避免两套边界判断语义漂移。 + return allowed.some(a => { + const relative = path.relative(path.resolve(a), resolved); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + }); } contextBridge.exposeInMainWorld('diagnosticAPI', { diff --git a/electron/main.js b/electron/main.js index a39677ef..2c4bfa56 100644 --- a/electron/main.js +++ b/electron/main.js @@ -844,6 +844,34 @@ app.on('web-contents-created', (event, contents) => { shell.openExternal(validation.url); } }); + + // SECURITY: 主窗口导航白名单。 + // 原因:setWindowOpenHandler 只拦截 window.open,renderer 仍可通过 location 赋值 + // 把整个窗口导航到任意站点;一旦成功,本地 file:// 源即被替换。 + // 未放行 http(s) 外站:外部链接统一由 new-window/openExternal 走系统浏览器。 + // dev 放行 Vite 开发服务器(含 HMR 触发的整页重载),生产仅放行 file:// 应用自身。 + contents.on('will-navigate', (event, navigationUrl) => { + let allowed = false; + try { + const parsed = new URL(navigationUrl); + if (parsed.protocol === 'file:') { + allowed = true; + } else if ( + isDevMode && + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') && + parsed.port === new URL(CONFIG.frontendDevUrl).port + ) { + allowed = true; + } + } catch { + allowed = false; + } + if (!allowed) { + log(`[SECURITY] Blocked navigation to ${navigationUrl}`, 'warning'); + event.preventDefault(); + } + }); }); // Handle certificate errors in development diff --git a/frontend/index.html b/frontend/index.html index 4e248afd..4eb32615 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,10 @@ - + + Papyrus Desktop