fix: 加固链接/图标安全与版本一致性;sync-articles 对齐 best-effort

- 模板与运行时统一做 URL scheme 白名单校验(不安全降级为 #),并清洗 icon class;分类标题/新增分类改用 DOM API 避免 innerHTML 注入
- sync-articles 主入口异常不再返回非 0 退出码,避免阻断 build/deploy
- window.MeNav.version 改为从 meta menav-version/配置自动读取,避免写死版本
- 文档/配置:新增 security.allowedSchemes 配置说明
This commit is contained in:
rbetree
2026-01-04 18:18:57 +08:00
parent 4cc10dd2b2
commit 9929f60170
9 changed files with 288 additions and 88 deletions

View File

@@ -236,6 +236,62 @@ function faviconFallbackUrl(url, options) {
}
}
/**
* 安全 URL 输出:用于 href 等场景,防止 javascript: 等危险 scheme 变成可点击链接
* - 默认允许http/https/mailto/tel + 相对链接(# / ./ ../ ?
* - 允许通过 site.security.allowedSchemes 扩展白名单(例如 obsidian/vscode
* @param {string} url 输入 URL
* @param {Object} options Handlebars options 对象
* @returns {string} 安全的 URL不安全时返回 #
* @example <a href="{{safeUrl url}}">...</a>
*/
function safeUrl(url, options) {
const raw = String(url || '').trim();
if (!raw) return '#';
// 允许相对链接
if (
raw.startsWith('#') ||
raw.startsWith('/') ||
raw.startsWith('./') ||
raw.startsWith('../') ||
raw.startsWith('?')
) {
return raw;
}
// 拒绝协议相对 URL//example.com避免绕过策略
if (raw.startsWith('//')) {
console.warn(`[WARN] 已拦截不安全 URL协议相对形式${raw}`);
return '#';
}
const allowedFromConfig =
options &&
options.data &&
options.data.root &&
options.data.root.site &&
options.data.root.site.security &&
options.data.root.site.security.allowedSchemes;
const allowedSchemes = Array.isArray(allowedFromConfig) && allowedFromConfig.length > 0
? allowedFromConfig
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
.filter(Boolean)
: ['http', 'https', 'mailto', 'tel'];
try {
const parsed = new URL(raw);
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
if (allowedSchemes.includes(scheme)) return raw;
console.warn(`[WARN] 已拦截不安全 URL scheme${raw}`);
return '#';
} catch (e) {
console.warn(`[WARN] 已拦截无法解析的 URL${raw}`);
return '#';
}
}
// 导出所有工具类助手函数
module.exports = {
slice,
@@ -249,5 +305,6 @@ module.exports = {
encodeURIComponent: encodeURIComponentHelper,
add,
faviconUrl,
faviconFallbackUrl
faviconFallbackUrl,
safeUrl
};

View File

@@ -17,6 +17,99 @@ function menavExtractDomain(url) {
}
}
// URL 安全策略:默认仅允许 http/https可加 mailto/tel与相对链接其他 scheme 降级为 '#'
function menavGetAllowedUrlSchemes() {
try {
const cfg =
window.MeNav && typeof window.MeNav.getConfig === 'function'
? window.MeNav.getConfig()
: null;
const fromConfig =
cfg && cfg.data && cfg.data.site && cfg.data.site.security && cfg.data.site.security.allowedSchemes;
if (Array.isArray(fromConfig) && fromConfig.length > 0) {
return fromConfig
.map(s => String(s || '').trim().toLowerCase().replace(/:$/, ''))
.filter(Boolean);
}
} catch (e) {
// 忽略,回退默认
}
return ['http', 'https', 'mailto', 'tel'];
}
function menavIsRelativeUrl(url) {
const s = String(url || '').trim();
return s.startsWith('#') || s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.startsWith('?');
}
function menavSanitizeUrl(rawUrl, contextLabel) {
if (rawUrl === undefined || rawUrl === null) return '#';
const url = String(rawUrl).trim();
if (!url) return '#';
if (menavIsRelativeUrl(url)) return url;
// 明确拒绝协议相对 URL//example.com避免意外绕过策略
if (url.startsWith('//')) {
console.warn(`[MeNav][安全] 已拦截不安全 URL协议相对形式${contextLabel || ''}`, url);
return '#';
}
try {
const parsed = new URL(url);
const scheme = String(parsed.protocol || '').toLowerCase().replace(/:$/, '');
const allowed = menavGetAllowedUrlSchemes();
if (allowed.includes(scheme)) return url;
console.warn(`[MeNav][安全] 已拦截不安全 URL scheme${contextLabel || ''}`, url);
return '#';
} catch (e) {
// 既不是可识别的绝对 URL也不是允许的相对 URL
console.warn(`[MeNav][安全] 已拦截无法解析的 URL${contextLabel || ''}`, url);
return '#';
}
}
// class token 清洗:仅允许字母/数字/下划线/中划线与空格分隔,避免属性/事件注入
function menavSanitizeClassList(rawClassList, contextLabel) {
const input = String(rawClassList || '').trim();
if (!input) return '';
const tokens = input
.split(/\s+/g)
.map(t => t.trim())
.filter(Boolean)
.map(t => t.replace(/[^\w-]/g, ''))
.filter(Boolean);
const sanitized = tokens.join(' ');
if (sanitized !== input) {
console.warn(`[MeNav][安全] 已清洗不安全的 icon class${contextLabel || ''}`, rawClassList);
}
return sanitized;
}
// 版本号统一来源:优先读取 meta[menav-version],回退到 menav-config-data.version
function menavDetectVersion() {
try {
const meta = document.querySelector('meta[name="menav-version"]');
const v = meta ? String(meta.getAttribute('content') || '').trim() : '';
if (v) return v;
} catch (e) {
// 忽略
}
try {
const configData = document.getElementById('menav-config-data');
const raw = configData ? String(configData.textContent || '').trim() : '';
if (!raw) return '1.0.0';
const parsed = JSON.parse(raw);
const v = parsed && parsed.version ? String(parsed.version).trim() : '';
return v || '1.0.0';
} catch (e) {
return '1.0.0';
}
}
// 修复移动端 `100vh` 视口高度问题:用实际可视高度驱动布局,避免侧边栏/内容区底部被浏览器 UI 遮挡
function menavUpdateAppHeight() {
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
@@ -36,7 +129,7 @@ let menavConfigCacheValue = null;
// 全局MeNav对象 - 用于浏览器扩展
window.MeNav = {
version: "1.0.0",
version: menavDetectVersion(),
// 获取配置数据
getConfig: function(options) {
@@ -90,12 +183,14 @@ window.MeNav = {
const element = this._findElement(type, id);
if (!element) return false;
if (type === 'site') {
// 更新站点卡片
if (newData.url) {
element.href = newData.url;
element.setAttribute('data-url', newData.url);
}
if (type === 'site') {
// 更新站点卡片
if (newData.url) {
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(site).url');
element.setAttribute('href', safeUrl);
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
element.setAttribute('data-url', String(newData.url).trim());
}
if (newData.name) {
element.querySelector('h3').textContent = newData.name;
element.setAttribute('data-name', newData.name);
@@ -112,7 +207,7 @@ window.MeNav = {
element.querySelector('i');
if (iconElement) {
const nextIconClass = String(newData.icon || '').trim();
const nextIconClass = menavSanitizeClassList(newData.icon, 'updateElement(site).icon');
const preservedClasses = [];
if (iconElement.classList.contains('icon-fallback')) {
@@ -127,7 +222,7 @@ window.MeNav = {
preservedClasses.forEach(cls => iconElement.classList.add(cls));
}
}
element.setAttribute('data-icon', newData.icon);
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(site).data-icon'));
}
if (newData.title) element.title = newData.title;
@@ -144,15 +239,21 @@ window.MeNav = {
if (newData.name) {
const titleElement = element.querySelector('h2');
if (titleElement) {
// 保留图标
const iconElement = titleElement.querySelector('i');
const iconClass = iconElement ? iconElement.className : '';
titleElement.innerHTML = `<i class="${newData.icon || iconClass}"></i> ${newData.name}`;
const nextIcon = menavSanitizeClassList(newData.icon || iconClass, 'updateElement(category).icon');
// 用 DOM API 重建标题,避免 innerHTML 注入
titleElement.textContent = '';
const nextIconEl = document.createElement('i');
if (nextIcon) nextIconEl.className = nextIcon;
titleElement.appendChild(nextIconEl);
titleElement.appendChild(document.createTextNode(' ' + String(newData.name)));
}
element.setAttribute('data-name', newData.name);
}
if (newData.icon) {
element.setAttribute('data-icon', newData.icon);
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(category).data-icon'));
}
// 触发元素更新事件
@@ -175,9 +276,9 @@ window.MeNav = {
if (newData.icon) {
const iconElement = element.querySelector('i');
if (iconElement) {
iconElement.className = newData.icon;
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(nav-item).icon');
}
element.setAttribute('data-icon', newData.icon);
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(nav-item).data-icon'));
}
// 触发元素更新事件
@@ -188,12 +289,14 @@ window.MeNav = {
});
return true;
} else if (type === 'social-link') {
// 更新社交链接
if (newData.url) {
element.href = newData.url;
element.setAttribute('data-url', newData.url);
}
} else if (type === 'social-link') {
// 更新社交链接
if (newData.url) {
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(social-link).url');
element.setAttribute('href', safeUrl);
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
element.setAttribute('data-url', String(newData.url).trim());
}
if (newData.name) {
const textElement = element.querySelector('.nav-text');
if (textElement) {
@@ -204,9 +307,9 @@ window.MeNav = {
if (newData.icon) {
const iconElement = element.querySelector('i');
if (iconElement) {
iconElement.className = newData.icon;
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(social-link).icon');
}
element.setAttribute('data-icon', newData.icon);
element.setAttribute('data-icon', menavSanitizeClassList(newData.icon, 'updateElement(social-link).data-icon'));
}
// 触发元素更新事件
@@ -267,36 +370,40 @@ window.MeNav = {
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
const siteForceIconMode =
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
? siteForceIconModeRaw
: '';
const siteForceIconMode =
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
? siteForceIconModeRaw
: '';
newSite.href = siteUrl;
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
if (/^https?:\/\//i.test(siteUrl)) {
newSite.target = '_blank';
newSite.rel = 'noopener';
}
const safeSiteUrl = menavSanitizeUrl(siteUrl, 'addElement(site).url');
const safeSiteIcon = menavSanitizeClassList(siteIcon, 'addElement(site).icon');
newSite.setAttribute('href', safeSiteUrl);
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); // 添加自定义 tooltip
if (/^https?:\/\//i.test(safeSiteUrl)) {
newSite.target = '_blank';
newSite.rel = 'noopener';
}
// 设置数据属性
newSite.setAttribute('data-type', 'site');
newSite.setAttribute('data-name', siteName);
newSite.setAttribute('data-url', data.url || '');
newSite.setAttribute('data-icon', siteIcon);
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
newSite.setAttribute('data-description', siteDescription);
newSite.setAttribute('data-type', 'site');
newSite.setAttribute('data-name', siteName);
// 保留原始 URLdata-url供扩展/调试读取href 仍会做安全降级
newSite.setAttribute('data-url', String(data.url || '').trim());
newSite.setAttribute('data-icon', safeSiteIcon);
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
newSite.setAttribute('data-description', siteDescription);
// projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑)
if (siteCardStyle === 'repo') {
const repoHeader = document.createElement('div');
repoHeader.className = 'repo-header';
const repoIcon = document.createElement('i');
repoIcon.className = `${siteIcon || 'fas fa-code'} repo-icon`;
repoIcon.setAttribute('aria-hidden', 'true');
const repoIcon = document.createElement('i');
repoIcon.className = `${safeSiteIcon || 'fas fa-code'} repo-icon`;
repoIcon.setAttribute('aria-hidden', 'true');
const repoTitle = document.createElement('div');
repoTitle.className = 'repo-title';
@@ -417,8 +524,8 @@ window.MeNav = {
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
placeholder.setAttribute('aria-hidden', 'true');
const fallback = document.createElement('i');
fallback.className = `${siteIcon} icon-fallback`;
const fallback = document.createElement('i');
fallback.className = `${safeSiteIcon} icon-fallback`;
fallback.setAttribute('aria-hidden', 'true');
const favicon = document.createElement('img');
@@ -452,14 +559,14 @@ window.MeNav = {
iconContainer.appendChild(favicon);
iconContainer.appendChild(fallback);
iconWrapper.appendChild(iconContainer);
} else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) {
// 根据 icons.region 配置决定优先使用哪个域名
const faviconUrlPrimary = iconsRegion === 'cn'
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
const faviconUrlFallback = iconsRegion === 'cn'
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
} else if (effectiveIconsMode === 'favicon' && safeSiteUrl && /^https?:\/\//i.test(safeSiteUrl)) {
// 根据 icons.region 配置决定优先使用哪个域名
const faviconUrlPrimary = iconsRegion === 'cn'
? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
: `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
const faviconUrlFallback = iconsRegion === 'cn'
? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`
: `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`;
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
@@ -468,8 +575,8 @@ window.MeNav = {
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
placeholder.setAttribute('aria-hidden', 'true');
const fallback = document.createElement('i');
fallback.className = `${siteIcon} icon-fallback`;
const fallback = document.createElement('i');
fallback.className = `${safeSiteIcon} icon-fallback`;
fallback.setAttribute('aria-hidden', 'true');
const favicon = document.createElement('img');
@@ -522,12 +629,12 @@ window.MeNav = {
iconContainer.appendChild(favicon);
iconContainer.appendChild(fallback);
iconWrapper.appendChild(iconContainer);
} else {
const iconEl = document.createElement('i');
iconEl.className = `${siteIcon} site-icon`;
iconEl.setAttribute('aria-hidden', 'true');
iconWrapper.appendChild(iconEl);
}
} else {
const iconEl = document.createElement('i');
iconEl.className = `${safeSiteIcon} site-icon`;
iconEl.setAttribute('aria-hidden', 'true');
iconWrapper.appendChild(iconEl);
}
newSite.appendChild(iconWrapper);
newSite.appendChild(contentWrapper);
@@ -565,16 +672,27 @@ window.MeNav = {
// 设置数据属性
newCategory.setAttribute('data-type', 'category');
newCategory.setAttribute('data-name', data.name || '未命名分类');
newCategory.setAttribute('data-icon', data.icon || 'fas fa-folder');
newCategory.setAttribute('data-icon', menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).data-icon'));
newCategory.setAttribute('data-container', 'categories');
// 添加内容
newCategory.innerHTML = `
<h2 data-editable="category-name"><i class="${data.icon || 'fas fa-folder'}"></i> ${data.name || '未命名分类'}</h2>
<div class="sites-grid" data-container="sites">
<p class="empty-sites">暂无网站</p>
</div>
`;
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
const titleEl = document.createElement('h2');
titleEl.setAttribute('data-editable', 'category-name');
const iconEl = document.createElement('i');
iconEl.className = menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).icon');
titleEl.appendChild(iconEl);
titleEl.appendChild(document.createTextNode(' ' + String(data.name || '未命名分类')));
const sitesGrid = document.createElement('div');
sitesGrid.className = 'sites-grid';
sitesGrid.setAttribute('data-container', 'sites');
const emptyEl = document.createElement('p');
emptyEl.className = 'empty-sites';
emptyEl.textContent = '暂无网站';
sitesGrid.appendChild(emptyEl);
newCategory.appendChild(titleEl);
newCategory.appendChild(sitesGrid);
// 添加到DOM
parent.appendChild(newCategory);