Skip to content

fix(security): security hardening from deep audit (SSRF, auth fail-closed, CSP, deps) - #33

Merged
Alpaca233114514 merged 8 commits into
mainfrom
security/hardening-2026-08
Aug 22, 2026
Merged

fix(security): security hardening from deep audit (SSRF, auth fail-closed, CSP, deps)#33
Alpaca233114514 merged 8 commits into
mainfrom
security/hardening-2026-08

Conversation

@Alpaca233114514

Copy link
Copy Markdown
Collaborator

安全加固 PR(基于深度扫描复核)

扫描凭证:Mimosa deep scan scan-2026-08-22T15-57-26.037Z-0ef797d0656a,seal sha256:208d37ce963264aab6c6a317f9f26dd44d527f6e046aaf094d1a9fbe1fe52936(105 findings + 754 依赖扫描)。逐条人工复核后确认 12 项真实问题,本 PR 全部修复;误报与搁置项见下。

修复清单

后端运行时

  1. SSRF 加固:连接期 DNS 校验(undici Agent connect.lookup,无 TOCTOU,防 DNS rebinding)+ redirect:'error'(堵 302 跳内网)+ 非本地调用私网/回环字面量预检;收口 ai-config / ai-completion / Ollama 路径的裸 fetch。keyless 本地 Provider(Ollama/LM Studio)显式放行回环,私网仍禁。
  2. 认证 fail-closed:token 读不到时 validateRequestToken 从"放行全部"改为"拒绝全部";认证钩子无条件注册;ensureAuthToken 无法持久化 token 时启动失败并给出诊断(数据目录只读不再静默裸奔)。
  3. MCP:bearer 比较改 timingSafeEqual;CORS 端口白名单同步收紧。
  4. 错误信息泄露:路由 catch 回显的原始 err.message(SQLite 错误/绝对路径)改为 debug 门控消毒(routeErrorMessage),服务端日志保留全文;入参校验类消息保留原文。
  5. CORS:主服务删除无应用使用的 3000/9100/9200 端口(任意本机进程绑定即可获得 credentials 可信 origin 的问题)。

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 的 startsWithpath.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 确认进入产物。

已核实为误报(未改动)

  • 21 条"敏感操作无角色权限检查":单用户本地应用,全局 token 钩子覆盖全部 /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 自伤面;本轮所有合规重写方案均被安全写入钩子拦截(模板插值/动态参数均不放行),留待单独处理。
  • renderer 持有完整 token(SSE/WS/<img> 无法带 header 的架构权衡,已有路由级限制)。
  • 第三方 GitHub API 镜像(update.ts):下载 URL 已域名钉扎+前端二次校验+无自动执行,考虑国内网络保留。
  • 依赖侧余量(moderate/low,dev 侧):frontend dompurify/markdown-it、backend markdown-it/@babel/core/esbuild、root joi——建议后续单独小版本处理(dompurify 优先)。

行为变化提示

  • 数据目录不可写时后端将拒绝启动(原为无认证继续运行)。
  • AI 出站请求不再跟随重定向(正常 OpenAI 兼容端点不受影响)。
  • 非 dev 模式下路由错误响应为通用文案(debug 详见服务端日志)。

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).
@Alpaca233114514 Alpaca233114514 self-assigned this Aug 22, 2026
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.
@Alpaca233114514
Alpaca233114514 merged commit 84c2d09 into main Aug 22, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant