Skip to content

Commit 3132871

Browse files
fix(seo): codeql ReDoS and incomplete HTML sanitization
CodeQL 在 PR 341 报 3 个 alert: 1. js/redos (line 430,2 处 backtracking 模式) surgical edit 用的 /^description:.*(?:\n(?:[ \t]+.*|\s*))*?(?=\n[\w-]+:|$)/m 内层 (?:[ \t]+.*|\s*) 在 \n 上 ambiguous,最坏情况指数回溯。 改成逐行扫描:找 description: 起始行,往下吃缩进/空行直到下一个顶级 yaml 键。逻辑一致但无 ReDoS 风险,4 个 yaml frontmatter case 测过。 2. js/incomplete-multi-character-sanitization (line 126) cleanBody 的 <[^>]+> 单次 replace 后嵌套残留(如 <<script>>)。 改成循环 replace 直到 stable,确保所有标签完全清理。 脚本只在离线 backfill 用,不接收外部输入;修复是为消除 CodeQL 高危告警 让 PR 能 merge。
1 parent e981ff5 commit 3132871

1 file changed

Lines changed: 45 additions & 15 deletions

File tree

scripts/generate-descriptions.mjs

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,13 @@ function cleanBody(body) {
122122
s = s.replace(/```[\s\S]*?```/g, "");
123123
// 行内代码
124124
s = s.replace(/`[^`\n]+`/g, "");
125-
// MDX/HTML 标签(粗暴去掉所有 <...>)
126-
s = s.replace(/<[^>]+>/g, "");
125+
// MDX/HTML 标签:循环 replace 直到 stable,避免嵌套残留如 "<<script>>"
126+
// (单次 replace 后剩 "<script>" 仍含 < — CodeQL js/incomplete-multi-character-sanitization)
127+
let prev;
128+
do {
129+
prev = s;
130+
s = s.replace(/<[^<>]*>/g, "");
131+
} while (s !== prev);
127132
// 图片/链接的 markdown 语法,保留可读文本
128133
s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
129134
s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
@@ -423,28 +428,53 @@ function writeFrontmatterDescription(relPath, newDescription) {
423428

424429
const [, open, body, close, rest] = fmMatch;
425430

426-
// 在 body 里定位 description 块:
427-
// "description:" 起头行 + 0 或多个缩进续行(YAML block scalar 或 quoted multi-line)
428-
// 终止于下一个顶级 yaml 键(行首匹配 `\w+:`)或 body 结尾
429-
const descriptionBlockRe =
430-
/^description:.*(?:\n(?:[ \t]+.*|\s*))*?(?=\n[\w-]+:|$)/m;
431+
// 在 body 里定位 description 块用**逐行扫描**,不用单一巨型正则:
432+
// 之前用 /^description:.*(?:\n(?:[ \t]+.*|\s*))*?(?=\n[\w-]+:|$)/m 触发了
433+
// CodeQL js/redos —— 内层 (?:[ \t]+.*|\s*) 在 \n 上 ambiguous,指数回溯。
434+
// 改逐行:找 "description:" 起始行,往下吃缩进续行(YAML block scalar /
435+
// multi-line quoted),遇到下一个顶级 yaml 键(行首 `\w+:`)或 body 结尾停。
436+
const lines = body.split("\n");
437+
const TOP_LEVEL_KEY_RE = /^[\w-]+:/; // 顶级 yaml 键的标志
438+
let descStart = -1;
439+
for (let i = 0; i < lines.length; i++) {
440+
if (lines[i].startsWith("description:")) {
441+
descStart = i;
442+
break;
443+
}
444+
}
431445

432446
let newBody;
433-
if (descriptionBlockRe.test(body)) {
434-
newBody = body.replace(descriptionBlockRe, newLine);
447+
if (descStart >= 0) {
448+
// 找 description 块结束:descStart+1 起,第一行命中顶级键或空行段后再命中顶级键的位置
449+
let descEnd = descStart;
450+
for (let i = descStart + 1; i < lines.length; i++) {
451+
const line = lines[i];
452+
// 缩进/空行视为 description 续行
453+
if (line === "" || /^[ \t]/.test(line)) {
454+
descEnd = i;
455+
continue;
456+
}
457+
// 顶级键出现:description 块到 descEnd 为止
458+
if (TOP_LEVEL_KEY_RE.test(line)) break;
459+
// 其他情况(理论上不应该出现):也归 description 续行兜底
460+
descEnd = i;
461+
}
462+
// 替换 [descStart, descEnd] 这段为单行 newLine
463+
const before = lines.slice(0, descStart);
464+
const after = lines.slice(descEnd + 1);
465+
newBody = [...before, newLine, ...after].join("\n");
435466
} else {
436-
// 没有 description 字段,插在首行(一般 title 后)
437-
const lines = body.split("\n");
438-
// 找到第一个非空 yaml 键行后插入(保持 title 在前的常见风格)
467+
// 没有 description 字段,插在首个顶级 yaml 键行后(一般是 title 后)
439468
let insertAt = 1;
440469
for (let i = 0; i < lines.length; i++) {
441-
if (/^[\w-]+:/.test(lines[i])) {
470+
if (TOP_LEVEL_KEY_RE.test(lines[i])) {
442471
insertAt = i + 1;
443472
break;
444473
}
445474
}
446-
lines.splice(insertAt, 0, newLine);
447-
newBody = lines.join("\n");
475+
const newLines = [...lines];
476+
newLines.splice(insertAt, 0, newLine);
477+
newBody = newLines.join("\n");
448478
}
449479

450480
fs.writeFileSync(abs, open + newBody + close + rest, "utf-8");

0 commit comments

Comments
 (0)