This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -35,8 +35,22 @@
* 浏览器对 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 文件名修复。
// 相对路径分支:
// 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`)。
// `data:image/...` 在顶层 `(?!)` 里只拒 `data:text/html`(白名单分支仍允许
// `data:image/png` 等栅格格式);非 IMG URI 由 isDangerousUriNonImg 兜底
// javascript:/vbscript:/file:/data(?!:image\/),IMG 由 installHooks 的额外检查兜底。
const ALLOWED_URI_REGEXP =
/^(?:(?:https?|mailto|tel|callto|file):|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,|#|(?:(?![/\\]{2})[/\\]|[^a-z/\\])|[a-z+.-]+(?:[\\/][^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+.-]+(?:[\\/][^/\\#]*)*)$/i;
// data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
@@ -95,6 +109,9 @@ const DANGEROUS_STYLE_PATTERNS = [
// url(javascript:...) / url("javascript:...") / url('javascript:...')
// 容忍可选空白 + 单/双引号包裹 + 大小写
/url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i,
// 协议相对 URL(url(//evil.com/...)):浏览器按当前页协议补全为 http(s)://,
// 绕过上面的 scheme 白名单,造成外部资源请求(IP / Referer / UA 泄露)。
/url\s*\(\s*['"]?\s*\/\//i,
// legacy IE CSS expression()
/expression\s*\(/i,
// legacy IE HTC behavior
@@ -187,6 +204,18 @@ 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;<script>` 等 `;` 后紧接 script payload 的边界情况。
if (/^\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|about\s*:)/i.test(data.attrValue)) {
data.keepAttr = false;
return;
}
// audit fix (Round 13 / Sec-H2):file:// 必须严格 file:///... 三斜杠形式
// —— 拒绝 file://host/... 与 file:////host/...。
//
@@ -207,7 +236,7 @@ function installHooks(DOMPurifyInstance) {
// 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。
// 文件绝对路径/file:/https: 不进此分支,行为不变。
data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH
&& /^data:image\//i.test(data.attrValue)
&& /^data:image\//i.test(data.attrValue.trim())
) {
data.keepAttr = false;
}

View File

@@ -51,7 +51,7 @@ const DEFAULT_SETTINGS = Object.freeze({
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
readerFontSize: 17, // 阅读字号 (px),范围 12..24
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
fileListSort: 'name', // 'name' | 'mtime-desc'
fileListSort: 'name', // 'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'
autoSaveDebounceMs: AUTO_SAVE_DEFAULT_DEBOUNCE_MS, // 防抖延迟(ms);0 = 关闭;默认 500ms
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
sidebarWidth: null, // 侧栏拖拽后的宽度(px);null = 使用 CSS 默认 --w-sidebar
@@ -118,6 +118,9 @@ const SETTINGS_SCHEMA = Object.freeze({
type: 'number',
min: 1.4,
max: 2.2,
// 与 readerFontSize / autoSaveDebounceMs / splitRatio 一致启用 clamp,
// 手改越界值落盘时夹到范围内而非静默回落默认。
clamp: true,
// fix(audit 2026-08):round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数,round:2 才能保留用户的选择。
round: 2,
@@ -125,7 +128,7 @@ const SETTINGS_SCHEMA = Object.freeze({
},
fileListSort: {
type: 'enum',
enum: ['name', 'mtime-desc'],
enum: ['name', 'name-desc', 'mtime-desc', 'mtime-asc'],
description: '侧栏文件列表排序',
},
autoSaveDebounceMs: {
@@ -215,7 +218,9 @@ const SETTINGS_UI_OPTIONS = Object.freeze({
]),
fileListSort: Object.freeze([
{ value: 'name', label: '按名称', hint: 'A → Z,localeCompare(zh-CN)' },
{ value: 'name-desc', label: '按名称', hint: 'Z → A,localeCompare(zh-CN) 反向' },
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
{ value: 'mtime-asc', label: '按修改时间', hint: '最早修改排在最前' },
]),
// 自动保存不再需要枚举选项:debounce 延迟是连续数值(0..60000 ms),
// 由 toolbar 按钮直接 toggle 0 ↔ 500,UI 也不再有「选几秒」的下拉。
@@ -515,7 +520,7 @@ function sanitizeSync(key, raw) {
// 注意:trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
// 但保留 nullable-path 上面已经拦截过的场景)。
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw;
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'nullable-path') ? raw.trim() : raw;
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKey(async 路径)
// 校验 format,coerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
@@ -537,20 +542,22 @@ function sanitizeSync(key, raw) {
}
case 'nullable-string': {
if (typeof raw !== 'string') return undefined;
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// 与 string 分支一致先 trim,避免未来字段(带 max / url-https format)静默放行前后空白。
const trimmed = raw.trim();
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
// fix(audit 2026-08):nullable-string 与 string 一样需要 format 校验,
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
if (raw.length > 0 && rule.format === 'url-https') {
if (!/^https?:\/\//i.test(raw)) return undefined;
if (trimmed.length > 0 && rule.format === 'url-https') {
if (!/^https?:\/\//i.test(trimmed)) return undefined;
let parsed;
try {
parsed = new URL(raw);
parsed = new URL(trimmed);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return raw;
return trimmed;
}
case 'boolean':
return typeof raw === 'boolean' ? raw : undefined;
@@ -589,7 +596,16 @@ function sanitizeSync(key, raw) {
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
// 与异步 validateKey 分支对称支持 clamp / round —— 否则同字段 update() clamp
// 但 load() 丢成默认,UI 行为漂移(audit 标注过的对称 bug)。
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
if (v < rule.min || v > rule.max) return undefined;
return v;
}