diff --git a/index.html b/index.html index daacb53..c13ef43 100644 --- a/index.html +++ b/index.html @@ -152,32 +152,6 @@ Notes
- - - -
@@ -274,8 +248,30 @@ - +
+ +
+ + +
diff --git a/main.js b/main.js index 5eb5644..ecd2d93 100644 --- a/main.js +++ b/main.js @@ -2510,9 +2510,19 @@ async function _prepareRelocate(event, srcPath, destDir) { return { error: { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取源状态失败') } }; } const srcIsDir = srcStat.isDirectory(); - // 文件夹不能移入自身或其子孙(防止循环 / 路径穿越) - if (srcIsDir && (destDir === srcPath || destDir.startsWith(srcPath + path.sep))) { - return { error: { ok: false, error: 'TARGET_INSIDE_SOURCE', code: 'TARGET_INSIDE_SOURCE', message: '不能把文件夹移入自身或其子文件夹' } }; + // 文件夹不能移入自身或其子孙(防止循环 / 路径穿越)。 + // audit #12 惯例(见 file:rename 的 isSamePath):先 resolve、win32 再折叠 + // 大小写 —— path 字符串可能以混合拼写到达('d:/notes/A' vs 'D:\notes\A', + // 同一 inode:陈旧 clipboard 跨 data-dir 重选 / 手改 config.json),原始串 + // 比对会绕过本守卫,之后 recursiveCopy 把副本套进自身目录无限嵌套直到 + // ENAMETOOLONG(move 场景则是 rename 到自身产生静默改名)。 + if (srcIsDir) { + const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s); + const srcFolded = fold(path.resolve(srcPath)); + const destFolded = fold(path.resolve(destDir)); + if (destFolded === srcFolded || destFolded.startsWith(srcFolded + path.sep)) { + return { error: { ok: false, error: 'TARGET_INSIDE_SOURCE', code: 'TARGET_INSIDE_SOURCE', message: '不能把文件夹移入自身或其子文件夹' } }; + } } // 解析目标名 + 重名避让(文件 / 文件夹走各自 resolver) const srcBase = path.basename(srcPath); @@ -3029,7 +3039,11 @@ ipcMain.handle('app:get-settings', (event) => { // P1-1 fix (audit):过滤 aiApiKey 真值,只露布尔标记。renderer 任何位置 // 通过 window.api.getSettings() 都拿不到完整 Key —— 必须显式调 // app:reveal-ai-key 才能拿真值(settings 对话框回显用)。 - return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey }; + // hasPendingAiKeyEnvelope:loadConfig 解密失败(密钥库不可用 / 包络损坏)时 + // 包络被挪出 appConfig(内存 aiApiKey 恒为明文的契约 → ''),但磁盘上 Key + // 真实存在 —— _hasAiKey 继续报 true,设置对话框走 arm/reveal 流程,reveal + // 显式失败 → _aiKeyRevealFailed 提示 + diff 守卫(空白输入不覆盖磁盘 Key)。 + return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey || configStore.hasPendingAiKeyEnvelope() }; }); /** @@ -3096,6 +3110,14 @@ ipcMain.handle('app:reveal-ai-key', (event) => { // L4 fix (audit):单行结构、sender + length,去掉任何 key 相关指纹; // 配 arm 日志一起能看出 arm→reveal 时序关系。 const keyStr = String(cfg.aiApiKey || ''); + if (!keyStr && configStore.hasPendingAiKeyEnvelope()) { + // 磁盘有包络但解密失败(密钥库不可用 / 包络损坏):显式报失败。 + // 不能回 ok:true + 空串 —— 虽然 renderer Round-11 守卫也会标记失败, + // 但这里显式拒绝保住「成功 reveal」日志的语义(length=0 的记录会误导 + // 事后审计「有人偷到了空 Key」)。 + console.warn('[reveal-ai-key] rejected: envelope present but undecryptable (keychain unavailable?)'); + return { ok: false, error: 'KEY_UNAVAILABLE' }; + } console.log('[reveal-ai-key] revealed by sender', senderId, 'length=', keyStr.length); return { ok: true, aiApiKey: keyStr }; }); diff --git a/main/config-store.js b/main/config-store.js index 8722df8..ada5e86 100644 --- a/main/config-store.js +++ b/main/config-store.js @@ -62,8 +62,18 @@ let appConfig = {}; let DEFAULT_DATA_DIR = null; let configLoaded = false; +// loadConfig 解密失败(密钥库不可用 / 包络损坏)时,磁盘上的 enc:v1: 包络挪到 +// 这里保存:内存 appConfig.aiApiKey 维持「始终是明文」契约(置空),saveConfig +// 本轮没动 Key 字段时从这里原样写回磁盘,避免把仍有效的加密 Key 覆盖成 '' 而 +// 丢失。不进 appConfig —— 原实现把包络留在 appConfig.aiApiKey,会经 reveal +// 把密文外漏给 renderer、_hasAiKey / AI 调用把密文当明文(契约违反)。 +let pendingAiKeyEnvelope = null; + function loadConfig() { const cfgPath = getConfigPath(); + // 每次读盘重新推导包络暂存(解密成功 → 保持 null / 解密失败 → 置入); + // ENOENT / JSON 损坏 / 解析异常路径也顺带清掉上一轮残留。 + pendingAiKeyEnvelope = null; let raw; try { raw = fsSync.readFileSync(cfgPath, 'utf-8'); @@ -87,12 +97,18 @@ function loadConfig() { // 上层(ai.js / settingsStore / renderer)零感知。懒迁移:旧版明文 // 没前缀,decrypt 原样返回,下一次 saveConfig 自动重新加密。 if (appConfig.aiApiKey) { - const decrypted = secretStore.decrypt(appConfig.aiApiKey); - // 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt 返回 '', - // 保留磁盘上的原始 enc:v1:... 包络,避免后续 saveConfig(如改主题)因内存值为 '' - // 而把仍有效的加密 Key 覆盖成空串、永久丢失。 + const rawKey = String(appConfig.aiApiKey); + const decrypted = secretStore.decrypt(rawKey); + // 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt + // 返回 '',把包络挪进 pendingAiKeyEnvelope(见模块顶部注释)、内存置空 —— + // 既保住「内存 aiApiKey 始终明文」契约(reveal / _hasAiKey / ai.js 不会 + // 拿到密文),又让后续 saveConfig(如改主题)能把包络原样写回、不丢 Key。 if (decrypted) { appConfig.aiApiKey = decrypted; + pendingAiKeyEnvelope = null; + } else { + appConfig.aiApiKey = ''; + pendingAiKeyEnvelope = rawKey.startsWith(secretStore.ENVELOPE_PREFIX) ? rawKey : null; } } } catch (e) { @@ -256,6 +272,11 @@ function saveConfig(next) { const toWrite = { ...merged }; if (toWrite.aiApiKey) { toWrite.aiApiKey = secretStore.encrypt(toWrite.aiApiKey); + } else if (pendingAiKeyEnvelope && !('aiApiKey' in next)) { + // 内存 aiApiKey 因解密失败被置空、本轮保存没动 Key 字段 → + // 包络从 pendingAiKeyEnvelope 原样写回磁盘,避免无关保存把仍有效 + // 的加密 Key 覆盖成空串。 + toWrite.aiApiKey = pendingAiKeyEnvelope; } await fh.writeFile(JSON.stringify(toWrite, null, 2), 'utf-8'); await fh.sync(); @@ -284,6 +305,8 @@ function saveConfig(next) { } // 写盘成功 → 同步到 in-memory cache appConfig = merged; + // 本轮显式改写了 Key(重填新值 / 主动清空)→ 旧包络作废。 + if ('aiApiKey' in next) pendingAiKeyEnvelope = null; return { ok: true, value: merged }; } catch (e) { if (fh) { try { await fh.close(); } catch { /* ignore */ } } @@ -579,6 +602,7 @@ function _reset() { appConfig = {}; DEFAULT_DATA_DIR = null; configLoaded = false; + pendingAiKeyEnvelope = null; // auto-fallback 2026-08:测试间清理缓存,避免上一个 case 残留 customDir // 命中率污染下一个 case 的首次 stat。 _resolveCache = null; @@ -619,4 +643,5 @@ module.exports = { _setApp, _isQuitting, _setIsQuitting, + hasPendingAiKeyEnvelope: () => !!pendingAiKeyEnvelope, }; diff --git a/shared/render-sanitize.js b/shared/render-sanitize.js index 85ab7a4..0e75aea 100644 --- a/shared/render-sanitize.js +++ b/shared/render-sanitize.js @@ -26,31 +26,42 @@ * —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头; * `(?![/\\]{2})` 显式拒绝「协议相对 URL」(`//evil.com/x.png` / * `\\evil.com\x.png`),避免静默导航到外站 - * —— `[a-z+.-]+(?:[\\/][^a-z]*|$)` 覆盖字母开头的相对路径 + * —— `[a-z.+#-]+(?:[\\/][^/\\]*)*` 覆盖字母开头的相对路径:首段允许 + * `.`/`#`/`+`/`-`(如 `page.md#chapter`),后续段允许 `:` 与 `#` + * (如 `dir/sub:dir/file.png`、`sprite.svg#icon-home`、`foo/bar#baz` + * —— 跨文件锚点跳转的 `文件#章节` 形态由 app.js resolveLocalFile 消费)。 * - * 危险协议 (javascript:/vbscript:/data:text\/html) 走「字母+冒号」分支——因 - * scheme 不在前列、不以 `#` 开头、不含 `/`,全部不匹配。 + * 危险协议 (javascript:/vbscript:/data:text\/html):顶层 `(?!)` 显式拒已知 + * 危险 scheme;其余未授权 scheme(`blob:x` / `chrome:x` / `ms-msdt:x` 等 + * 「scheme:无点 token」形态)因分支 A 要求「冒号 + 带点扩展名」不再命中 —— + * 维持白名单的默认拒绝。 * * Windows 路径分隔符 `\`:用户在 Windows 上常写 `![](img\foo.png)`, * 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此), * 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。 */ // 末尾 $ 锚整串;scheme / data:image / 纯锚点分支用 [^\s]* 兜底后续字符。 -// 顶层 `(?!)` 拒未在白名单的 scheme:相对路径分支 A (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`) -// 会让 `about:blank` / `javascript:foo` / `vbscript:foo` / `data:text/html` 同形态通过 -// —— 这里在外层把已知危险 / 未授权 scheme 统一拒掉,保留分支 A 的 Mac 文件名修复。 +// 顶层 `(?!)` 拒已知危险 scheme(about: / javascript: / vbscript: / +// data:text\/html)—— 它们与相对路径分支同形态,必须在外层先斩。 // 相对路径分支: -// A. `[a-z+.-]+:[a-z\d.\-_]+(?![/\\])` —— Mac/Linux 文件名带单冒号(如 -// `image:001.jpg`、`screenshot:1.png`),但 `:` 后不许是 `/` 或 `\` -// (否则会误吃 `data:text/html`、`javascript:foo/bar` 之类)。 -// B. `[a-z+.-]+(?:[\\/][^/\\#]*)*` —— 多段路径,支持 `img/a.png` / `../up.md` / -// `/abs/x.png` / Windows 反斜杠 / 路径段内含 `:`(如 `dir/sub:dir/file.png`) -// / 段内 #fragment(如 `sprite.svg#icon-home`、`page.md#chapter`)。 +// A. `[a-z+.-]+:[a-z\d.\-_]*\.[a-z\d]+(?![/\\])` —— Mac/Linux 文件名带单冒号 +// (如 `image:001.jpg`、`screenshot:1.png`)。要求冒号后是「带点扩展名」 +// 形态:`blob:x` / `chrome:x` / `ms-msdt:x` 这类「scheme:无点 token」不再 +// 命中(旧版 `[a-z\d.\-_]+` 不要求扩展名点,等于把默认拒绝白名单放宽成 +// 只拦 4 个 scheme 的黑名单)。`:` 后仍不许紧跟 `/` 或 `\`(否则会误吃 +// `data:text/html`、`javascript:foo/bar` 之类;这些也被顶层 `(?!)` 拦)。 +// B. `[a-z.+#-]+(?:[\\/][^/\\]*)*` —— 多段 / 单段路径,支持 `img/a.png` / +// `../up.md` / `/abs/x.png`(经前一分支)/ Windows 反斜杠 / 路径段内含 +// `:`(如 `dir/sub:dir/file.png`)/ 段内与末段 `#fragment`(如 +// `sprite.svg#icon-home`、`page.md#chapter`、`foo/bar#baz`——旧版段字符类 +// `[^/\\#]` 排除 `#`,把跨文件锚点跳转整类误拒,本分支修复)。首段类 +// `[a-z.+#-]` 不含 `:` 与分隔符,拼不出「scheme:」形态,与 A 的扩展名 +// 要求共同维持默认拒绝。 // `data:image/...` 在顶层 `(?!)` 里只拒 `data:text/html`(白名单分支仍允许 // `data:image/png` 等栅格格式);非 IMG URI 由 isDangerousUriNonImg 兜底 // javascript:/vbscript:/file:/data(?!:image\/),IMG 由 installHooks 的额外检查兜底。 const ALLOWED_URI_REGEXP = - /^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]+(?![/\\])|[a-z+.-]+(?:[\\/][^/\\#]*)*)$/i; + /^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]*\.[a-z\d]+(?![/\\])|[a-z.+#-]+(?:[\\/][^/\\]*)*)$/i; // data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图 // 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。 @@ -204,14 +215,13 @@ function installHooks(DOMPurifyInstance) { data.keepAttr = false; return; } - // 显式拒未在白名单的 scheme:ALLOWED_URI_REGEXP 的分支 A - // (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`) 允许 Mac/Linux 文件名带单 `:` - // (如 `image:001.jpg`),但形态上同 `javascript:foo` / `vbscript:foo` / - // `about:blank` —— 后者也会通过 ALLOWED。isDangerousUriNonImg 只跑非 IMG - // 路径且只覆盖危险 scheme,不含 `about:`,IMG 必须自己再拦一道。 - // data:text/html 在分支 A 里 `:text` 后续遇 `/` 被 `(?![/\\])` 否定、不通过; - // 这里保留是为了防御性深度扫描(与 audit Sec-D 风格一致),同时挡住形如 - // `data:text/html;