update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user