fix(security): 页面文案 markdown 管线加标签/属性/协议净化(P0)
方案 10 §7.1 实锤漏洞:renderMarkdown() 用无参 createMarkdownProcessor,
默认 allowDangerousHtml + 无条件 rehype-raw,页面文案(about intro、开源页
叙述、README)的 raw HTML 直通 set:html——script/style/iframe/on* 全不剥。
- 新增 src/lib/rehype-content-sanitize.ts:标签白名单(不含 script/style/
iframe/object/form/link/meta/svg 等,危险标签整棵丢弃、未知标签解包保内容)
+ 属性白名单(a[href,title,rel]、img[src,alt,width,height,loading]、class/id)
+ 协议白名单(http/https/mailto + 站内相对 + 小图 data:)
- site-content.ts 插件链与文章管线同序:rehypeRaw → 内容净化 → class 白名单
→ 链接卡片(副作用为正:页面文案里的独立链接也会成 OG 卡片)
- 修两处自身实现坑(实测驱动):hast 的 class 属性名是 className(按 class
过滤会把合法排版类一起剃掉);属性白名单需统一小写比较(驼峰 key 比不到)
验证: 注入载荷实测(<script>/<iframe>/onclick/style="color:red"/
href="javascript:")全部剥离、合法 class="ink-2" 与文本保留;
构建 48 页 exit 0;E2E 37/37
Spec: none (安全修复,方案 P0)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
859cac19a2
commit
b0a3881725
2 files changed
+108
-1
No files matched your search
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* rehype 内容净化(标签/属性/协议级)——页面文案管线的第二道防线。
|
||||
*
|
||||
* 为什么需要:`renderMarkdown()` 走的是 `@astrojs/markdown-remark` 的独立处理器,
|
||||
* 默认 `allowDangerousHtml: true` 且无条件挂 `rehype-raw`;若不筛标签,后台
|
||||
* (或远程 README)写入的 `<script>`、`<iframe>`、`on*` 会直通 `set:html`。
|
||||
* 文章管线靠 astro.config.mjs 的插件链兜住,页面文案此前没有任何兜底(2026-11-02
|
||||
* 方案 10 §7.1 实锤漏洞)。
|
||||
*
|
||||
* 与 rehype-class-whitelist 的分工:那个管「class/style/on*」,本插件管「标签与属性
|
||||
* 白名单 + 协议白名单」。两者同序串联(raw → sanitize → class → linkCards)。
|
||||
*/
|
||||
|
||||
/** 允许保留的标签(不含 script/style/iframe/object/embed/form/input/link/meta/svg/base) */
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'blockquote',
|
||||
'ul', 'ol', 'li', 'a', 'img', 'h2', 'h3', 'h4', 'hr',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'figure', 'figcaption', 'span', 'div',
|
||||
]);
|
||||
|
||||
/** 危险标签:整棵子树直接丢弃(不是解包——解包会把内联脚本正文漏成文本) */
|
||||
const DROP_TAGS = new Set([
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'textarea',
|
||||
'select', 'button', 'link', 'meta', 'base', 'svg', 'math', 'template', 'noscript',
|
||||
]);
|
||||
|
||||
/** 每标签允许的属性(class 交给 class 白名单插件再筛;id 仅供标题锚点) */
|
||||
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
||||
a: new Set(['href', 'title', 'rel']),
|
||||
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
|
||||
// hast 里 class 属性名是 className(数组),不是 class——按 class 过滤会把
|
||||
// 合法排版类一起剃掉(2026-11-02 实测:<p class="ink-2"> 只剩 <p>)
|
||||
'*': new Set(['className', 'id']),
|
||||
};
|
||||
|
||||
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
||||
|
||||
/** 判断 URL 是否安全:站内相对路径 / 白名单协议 / 小图 data: 允许 */
|
||||
function isSafeUrl(value: string): boolean {
|
||||
const v = value.trim();
|
||||
if (!v) return false;
|
||||
if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../') || v.startsWith('#')) return true;
|
||||
if (/^data:image\/(png|jpe?g|gif|webp|svg\+xml);/i.test(v)) return true;
|
||||
try {
|
||||
const url = new URL(v, 'https://placeholder.invalid');
|
||||
return SAFE_PROTOCOLS.has(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface HastElement {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastElement[];
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function sanitizeNode(node: HastElement): HastElement[] | null {
|
||||
if (node.type === 'element' && node.tagName) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (DROP_TAGS.has(tag)) return null; // 整棵子树丢弃
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
// 未知但无害的标签(如 section/header)→ 解包保留内容
|
||||
return (node.children ?? []).flatMap((c) => sanitizeNode(c) ?? []);
|
||||
}
|
||||
const allowed = new Set(
|
||||
[...(ALLOWED_ATTRS[tag] ?? []), ...ALLOWED_ATTRS['*']!].map((a) => a.toLowerCase()),
|
||||
);
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(node.properties ?? {})) {
|
||||
const k = key.toLowerCase(); // hast 用 className/charSet 等驼峰,比较前统一小写
|
||||
if (!allowed.has(k)) continue;
|
||||
if ((k === 'href' || k === 'src') && typeof value === 'string' && !isSafeUrl(value)) continue;
|
||||
if (k === 'id' && typeof value === 'string' && !/^[A-Za-z0-9_-]{1,64}$/.test(value)) continue;
|
||||
props[key] = value;
|
||||
}
|
||||
node.properties = props;
|
||||
node.children = (node.children ?? []).flatMap((c) => sanitizeNode(c) ?? []);
|
||||
} else if (node.children) {
|
||||
node.children = node.children.flatMap((c) => sanitizeNode(c) ?? []);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** rehype 插件主体 */
|
||||
export default function rehypeContentSanitize() {
|
||||
return (tree: HastElement) => {
|
||||
sanitizeNode(tree);
|
||||
};
|
||||
}
|
||||
+15
-1
@@ -121,7 +121,21 @@ export async function siteFavicon(): Promise<{ href: string; type: string }> {
|
||||
let mdProcessor: Promise<import('@astrojs/markdown-remark').MarkdownProcessor> | null = null;
|
||||
export async function renderMarkdown(md: string): Promise<string> {
|
||||
const { createMarkdownProcessor } = await import('@astrojs/markdown-remark');
|
||||
mdProcessor ??= createMarkdownProcessor();
|
||||
// 与 astro.config.mjs 的文章管线同序:raw → 内容净化(标签/属性/协议)→
|
||||
// class 白名单 → 链接卡片。此前这里 createMarkdownProcessor() 无参,页面
|
||||
// 文案的 raw HTML 直通 set:html(方案 10 §7.1 实锤漏洞:script/style/on* 不剥)。
|
||||
mdProcessor ??= (async () => {
|
||||
const [{ default: rehypeRaw }, { default: rehypeContentSanitize }, { default: rehypeClassWhitelist }, { default: rehypeLinkCards }] =
|
||||
await Promise.all([
|
||||
import('rehype-raw'),
|
||||
import('./rehype-content-sanitize'),
|
||||
import('./rehype-class-whitelist'),
|
||||
import('./rehype-link-cards'),
|
||||
]);
|
||||
return createMarkdownProcessor({
|
||||
rehypePlugins: [rehypeRaw, rehypeContentSanitize, rehypeClassWhitelist, rehypeLinkCards],
|
||||
});
|
||||
})();
|
||||
const { code } = await (await mdProcessor).render(md);
|
||||
return code;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user