fix(security): security hardening from deep audit (SSRF, auth fail-closed, CSP, deps) - #33
Merged
Merged
Conversation
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)
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)
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
- 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
build-electron.js executed shell strings assembled from paths and interpolated values. Every external command is now a fully static string (directory changes move into the cwd option, targets map to static switch arms), so no dynamic data can reach a shell. The version-sync path concatenation is replaced by a static relative command with a cwd. verify-packaged-app.js replaces the fixed smoke-test token with a per-run random value; it only ever lived in-process between the script and the backend it spawns, so randomization removes the hardcoded credential without any functional change.
Targeted, lockfile-respecting updates; no audit-fix --force, no tree rebuild. All critical/high advisories across the three lockfiles are resolved: - backend: tar (critical), undici, find-my-way, form-data, fast-uri, brace-expansion, js-yaml, linkify-it, sharp 0.34.5 -> 0.35.3 - frontend: concurrently, shell-quote (critical), vite, postcss, nanoid, linkify-it - root: tar, shell-quote (critical), electron, electron-builder stack, axios, undici, sharp Remaining advisories are moderate/low dev-side entries intentionally left in place per the agreed scope (frontend dompurify/markdown-it, backend markdown-it/@babel/core/esbuild, root joi).
The read-only-directory simulation relied on chmod, which privileged users (root in CI containers) simply ignore, so ensureAuthToken did not throw and the test failed on Linux CI. Occupy the token path with a directory instead: writeFileSync against a directory fails with EISDIR for every user and platform. Drop the POSIX-only skip since the setup now works on Windows too.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
安全加固 PR(基于深度扫描复核)
扫描凭证:Mimosa deep scan
scan-2026-08-22T15-57-26.037Z-0ef797d0656a,sealsha256:208d37ce963264aab6c6a317f9f26dd44d527f6e046aaf094d1a9fbe1fe52936(105 findings + 754 依赖扫描)。逐条人工复核后确认 12 项真实问题,本 PR 全部修复;误报与搁置项见下。修复清单
后端运行时
redirect:'error'(堵 302 跳内网)+ 非本地调用私网/回环字面量预检;收口 ai-config / ai-completion / Ollama 路径的裸fetch。keyless 本地 Provider(Ollama/LM Studio)显式放行回环,私网仍禁。validateRequestToken从"放行全部"改为"拒绝全部";认证钩子无条件注册;ensureAuthToken无法持久化 token 时启动失败并给出诊断(数据目录只读不再静默裸奔)。timingSafeEqual;CORS 端口白名单同步收紧。err.message(SQLite 错误/绝对路径)改为 debug 门控消毒(routeErrorMessage),服务端日志保留全文;入参校验类消息保留原文。Electron / 前端
6. CSP:meta CSP(file:// 生产加载下实际生效的那份)
connect-src移除https:,消除注入脚本外发通道;img-src保留 https: 供笔记远程图片。7. will-navigate:所有 webContents 拦截非应用自身的导航(此前仅拦 window.open)。
8. 下载按钮补
rel="noopener noreferrer";DOCX 预览改用严格 DOMPurify 白名单;diagnostic-preload 的startsWith改path.relative包含性判断。构建脚本
9. build-electron.js 全部外部命令改为纯静态字符串(动态数据零拼接)。
10. verify-packaged-app.js 冒烟 token 改为每次随机生成。
依赖
11. 三处 lockfile 的 critical/high 全部定点清零(tar/shell-quote critical;sharp 0.34.5→0.35.3 等),未用
audit fix --force。测试
12. 新增/更新:认证 fail-closed、SSRF 连接期校验矩阵(私网拒绝/回环门控/redirect 强制 error)、resolved-address 分类;CLI 入参校验消息保留。全量:后端 791 通过、typecheck 0 错误;前端 typecheck + build 通过、CSP 确认进入产物。
已核实为误报(未改动)
/api路由。parseSemVer/extractWikiLinks"命令注入":纯字符串/正则函数。llm-cache.ts:126/core/files.ts:179/core/notes.ts:197"路径穿越":路径来自 readdirSync 或服务端生成。papyrus-cli.ts:308:CLI 按 argv 读本地文件,by design。搁置项(后续处理)
generate-release-notes.js/download-artifacts.js/bump-version.js的 argv→git 命令注入面:真实但属 dev/CI 自伤面;本轮所有合规重写方案均被安全写入钩子拦截(模板插值/动态参数均不放行),留待单独处理。<img>无法带 header 的架构权衡,已有路由级限制)。行为变化提示