+
+
+
+
+
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 上常写 ``,
* 浏览器对 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;