Files
superpowers-zh/site/md.mjs
AI不止语 0aa3344f6f harden(site): 站点安全加固——严格 CSP + 安全响应头 + 链接 scheme 净化
安全审核发现两处防御纵深缺口(当前内容可信、非活跃漏洞,但公开站应补齐):

1. 部署站 _headers 此前只有 Cache-Control,缺所有安全响应头。
   现新增全站 /* 头:
   - Content-Security-Policy:default-src 'self';script-src 'self' + 本站
     内联脚本的 SHA-256 hash(构建时从生成 HTML 实测,禁用 unsafe-inline/
     unsafe-eval 又不误伤自有脚本);style-src 'self';object-src 'none';
     base-uri 'self';frame-ancestors 'none';form-action 'self'
   - X-Content-Type-Options: nosniff / X-Frame-Options: DENY /
     Referrer-Policy: no-referrer / Cross-Origin-Opener-Policy: same-origin /
     Permissions-Policy(关闭定位/麦克风/摄像头)
2. md.mjs 链接渲染未校验 scheme。现仅放行 http/https/mailto/锚点/相对
   路径,阻断 javascript:/data:/vbscript: 等可执行 scheme,并转义引号防
   属性逃逸。

验证:重建 42 页成功;独立核验全站 84 处内联脚本的 hash 全部命中 CSP
白名单(0 缺失 → CSP 不会打断站点);链接净化单测 5/5(正常/锚点/相对
保留,javascript:/data: 中和为 #);站点正常外链完好。外部资源仅 <a>
导航链接,不受 default-src 影响。
2026-06-20 04:16:40 +08:00

157 lines
5.2 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 零依赖 Markdown → HTML 渲染器,够用于本项目的 SKILL.md。
// 支持ATX 标题、代码围栏、表格、有/无序列表(含嵌套)、引用、分隔线、
// 行内 code/bold/italic/link原生 HTML 行透传。
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 行内:先保护代码片段,再处理粗体/斜体/链接
function inline(text) {
const codes = [];
// 行内代码 `...`
text = text.replace(/`([^`]+)`/g, (_, c) => {
codes.push('<code>' + escapeHtml(c) + '</code>');
return '' + (codes.length - 1) + '';
});
// 其余文本转义(保护已存在的原生 HTML本项目 SKILL.md 仅含可信内容)
// 链接 [text](url) —— 仅放行安全 schemehttp/https/mailto/锚点/相对路径),
// 阻断 javascript:/data:/vbscript: 等可执行 scheme并转义引号防属性逃逸
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, u) => {
const safe = /^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i.test(u) ? u : '#';
return `<a href="${safe.replace(/"/g, '&quot;')}" target="_blank" rel="noopener">${t}</a>`;
});
// 粗体 **x** / 斜体 *x*
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
// 还原代码片段
text = text.replace(/(\d+)/g, (_, i) => codes[+i]);
return text;
}
export function renderMarkdown(src) {
// 去掉 frontmatter
src = src.replace(/^---\n[\s\S]*?\n---\n?/, '');
const lines = src.split('\n');
const out = [];
let i = 0;
const listStack = []; // {type:'ul'|'ol', indent}
function closeListsTo(indent) {
while (listStack.length && listStack[listStack.length - 1].indent >= indent) {
out.push('</li>');
out.push('</' + listStack.pop().type + '>');
}
}
function closeAllLists() {
while (listStack.length) {
out.push('</li>');
out.push('</' + listStack.pop().type + '>');
}
}
while (i < lines.length) {
let line = lines[i];
// 代码围栏
const fence = line.match(/^```(\w*)/);
if (fence) {
closeAllLists();
const lang = fence[1] || '';
const buf = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++; // 跳过结束 ```
out.push(`<pre data-lang="${lang}"><code>${escapeHtml(buf.join('\n'))}</code></pre>`);
continue;
}
// 分隔线
if (/^---+\s*$/.test(line) || /^\*\*\*+\s*$/.test(line)) {
closeAllLists();
out.push('<hr>');
i++; continue;
}
// 标题
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
closeAllLists();
const lvl = h[1].length;
const id = h[2].trim().toLowerCase().replace(/[^\w一-龥]+/g, '-').replace(/^-|-$/g, '');
out.push(`<h${lvl} id="${id}">${inline(h[2].trim())}</h${lvl}>`);
i++; continue;
}
// 表格(当前行含 | 且下一行是分隔行)
if (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1])) {
closeAllLists();
const parseRow = r => r.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim());
const header = parseRow(line);
i += 2; // 跳过表头与分隔行
out.push('<div class="md-table"><table><thead><tr>' +
header.map(c => `<th>${inline(c)}</th>`).join('') + '</tr></thead><tbody>');
while (i < lines.length && /\|/.test(lines[i]) && lines[i].trim() !== '') {
const cells = parseRow(lines[i]);
out.push('<tr>' + cells.map(c => `<td>${inline(c)}</td>`).join('') + '</tr>');
i++;
}
out.push('</tbody></table></div>');
continue;
}
// 引用
if (/^>\s?/.test(line)) {
closeAllLists();
const buf = [];
while (i < lines.length && /^>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^>\s?/, '')); i++; }
out.push('<blockquote>' + inline(buf.join(' ')) + '</blockquote>');
continue;
}
// 列表项
const li = line.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
if (li) {
const indent = li[1].length;
const type = /\d+\./.test(li[2]) ? 'ol' : 'ul';
// 关闭比当前更深的层级
closeListsTo(indent + 1);
const top = listStack[listStack.length - 1];
if (!top || top.indent < indent) {
out.push('<' + type + '>');
listStack.push({ type, indent });
} else {
out.push('</li>');
}
out.push('<li>' + inline(li[3]));
i++; continue;
}
// 空行
if (line.trim() === '') {
closeAllLists();
i++; continue;
}
// 原生 HTML 行透传
if (/^\s*<\//.test(line) || /^\s*<[a-zA-Z]/.test(line)) {
closeAllLists();
out.push(line);
i++; continue;
}
// 段落(合并连续非空行)
closeAllLists();
const buf = [line];
i++;
while (i < lines.length && lines[i].trim() !== '' &&
!/^(#{1,6}\s|```|>\s?|---+\s*$|\s*([-*]|\d+\.)\s)/.test(lines[i]) &&
!(/\|/.test(lines[i]) && /^\s*\|?[\s:|-]+\|/.test(lines[i + 1] || ''))) {
buf.push(lines[i]); i++;
}
out.push('<p>' + inline(buf.join(' ')) + '</p>');
}
closeAllLists();
return out.join('\n');
}