fix(openclaw): patch Markdown reference denial of service (#105)

This commit is contained in:
okxlin
2026-10-22 14:20:30 +08:00
committed by GitHub
parent daa44ab0f8
commit b709fc656d
4 changed files with 137 additions and 0 deletions
@@ -59,6 +59,7 @@ jobs:
python3 scripts/test-resolve-openclaw-inputs.py
bash scripts/test-apply-openclaw-runtime-hardening.sh
node --test scripts/test-openclaw-npm-bundle.mjs scripts/test-openclaw-cache-semantics.mjs scripts/test-openclaw-source-map.mjs scripts/test-openclaw-mcp-auth.mjs
node --test scripts/test-openclaw-markdown.mjs
node --test scripts/test-openclaw-native-typescript.mjs
python3 scripts/test-evaluate-trivy-policy.py
python3 scripts/test-registry-image.py
+14
View File
@@ -39,6 +39,20 @@
"url": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"sha512": "2868fff18e37c77e5a6550edb7e27898ad61a0b18750b3187d290e0c93508cd0c2de85b53ea3e8c4cc28d292d4b1633f5041b338dfcdc5e1f2c047cd5cd3f757"
},
"markdown_packages": {
"argparse": {
"from": "3.0.2",
"version": "3.0.3",
"url": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
"sha512": "4a7f7c7ed57729fb0ef317cb3c71baa13f32f9bd32bdbacb8198e03c35ec85aacd635e6d4c08ea22c223bb84b7da797afe211434d138bfec8bd99866f28a0fdf"
},
"markdown-it": {
"from": "15.0.2",
"version": "15.0.3",
"url": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz",
"sha512": "f80cfb3a3cf78911aef0a60cbc9eb1561bebc4fdde13ebc4b495fbe4ec3cf17069710355ff49465f96729c82017ed444a380c2da3b2f78030369e96d9a6f10bd"
}
},
"mcp_packages": {
"@modelcontextprotocol/core": {
"from": "2.0.0",
@@ -190,6 +190,66 @@ export async function patchSourceMaps(root, pin) {
await replaceVerifiedDependency('source-map-js', pin, outdated, verifySourceMaps);
}
// https://github.com/markdown-it/markdown-it/security/advisories/GHSA-gfr2-rv44-r23c
export function verifyMarkdown(directory) {
// Bound the actual hostile render in a child: a vulnerable copy must not hang the build.
execFileSync(process.execPath, ['--input-type=module', '-e', `
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
const directory = process.argv[1];
const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json')));
const require = createRequire(path.join(directory, 'package.json'));
for (const MarkdownIt of [require(directory),
(await import(pathToFileURL(path.join(directory, pkg.module)))).default]) {
const md = new MarkdownIt();
assert.equal(md.render('**safe**'), '<p><strong>safe</strong></p>\\n');
assert.equal(md.render('[a]: /url "title"\\n\\n[a]'),
'<p><a href="/url" title="title">a</a></p>\\n');
assert.equal(md.render('[a]: /url "open\\nclose"\\n\\n[a]'),
'<p><a href="/url" title="open\\nclose">a</a></p>\\n');
for (const input of ['[a]: /u "' + 'x\\n'.repeat(300000),
'[a]: /u "' + 'x\\n'.repeat(300000) + '"', '[' + 'a\\n'.repeat(300000)]) {
assert.equal(typeof md.render(input), 'string');
}
}
`, directory], {timeout: 5000, stdio: 'pipe'});
const version = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'))).version;
assert.equal(execFileSync(process.execPath, [path.join(directory, 'bin/markdown-it.mjs'), '--version'],
{timeout: 5000, encoding: 'utf8'}).trim(), version);
console.log(`Verified CVE-2026-111758 Markdown rendering and CLI (${version}): ${directory}`);
}
export async function patchMarkdownPackages(root, pins) {
const store = path.join(fs.realpathSync(root), '.pnpm');
const markdown = [];
// The fixed Markdown CLI requires argparse >=3.0.3, with no new dependencies.
for (const name of ['argparse', 'markdown-it']) {
const pin = pins[name];
const outdated = [];
for (const entry of fs.readdirSync(store)) {
if (!entry.startsWith(`${name}@`)) continue;
if (name === 'argparse' && !entry.startsWith('argparse@3.')) continue;
const directory = path.join(store, entry, 'node_modules', name);
assert.equal(fs.realpathSync(directory), directory, 'unexpected Markdown dependency path');
const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json')));
assert.equal(pkg.name, name);
if (pkg.version === pin.from) outdated.push(directory);
else assert.equal(pkg.version, pin.version, `unreviewed ${name} version`);
if (name === 'markdown-it') markdown.push(directory);
}
await replaceVerifiedDependency(name, pin, outdated);
}
for (const directory of markdown) {
const require = createRequire(path.join(directory, 'package.json'));
const argparse = JSON.parse(fs.readFileSync(require.resolve('argparse/package.json')));
assert.equal(argparse.version, pins.argparse.version, 'Markdown CLI resolved the wrong argparse version');
verifyMarkdown(directory);
}
}
// Local workaround until upstream releases a fix for CVE-2026-93748.
// https://github.com/kornelski/http-cache-semantics/issues/56
// npm's dependency refresh can install 4.3.0, which retains the same unsafe stale path.
@@ -265,6 +325,7 @@ async function main() {
await patchNpmBundles('/app/node_modules', components.npm_bundled);
await patchSourceMaps('/app/node_modules', components.source_map_js);
await patchMcpPackages('/app/node_modules', components.mcp_packages);
await patchMarkdownPackages('/app/node_modules', components.markdown_packages);
const cacheFiles = execFileSync('find', ['/app/node_modules', '-type', 'f', '-path',
'*/http-cache-semantics/package.json'], {encoding: 'utf8'}).trim();
for (const file of cacheFiles ? cacheFiles.split('\n') : []) {
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { patchMarkdownPackages, verifyMarkdown } from '../openclaw-builder/image/patch-vendored-deps.mjs';
const pins = JSON.parse(fs.readFileSync(new URL('../openclaw-builder/configs/components.json', import.meta.url))).markdown_packages;
test('repairs the real Markdown denial of service and retains pnpm consumers and the CLI', {timeout: 120000}, async () => {
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-markdown-test-'));
try {
const dependencies = {'markdown-it': pins['markdown-it'].from, argparse: pins.argparse.from,
entities: '8.1.0', 'linkify-it': '6.1.0', mdurl: '2.1.0', 'punycode.js': '2.3.1', 'uc.micro': '3.0.0'};
fs.writeFileSync(path.join(temporary, 'package.json'), JSON.stringify({private: true, dependencies}));
execFileSync('npm', ['install', '--prefix', temporary, '--ignore-scripts', '--package-lock=false',
'--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'], {timeout: 90000, stdio: 'pipe'});
const root = path.join(temporary, 'node_modules');
const directories = {};
for (const [name, pin] of Object.entries(pins)) {
const original = path.join(root, name);
const directory = path.join(root, '.pnpm', `${name}@${pin.from}`, 'node_modules', name);
fs.mkdirSync(path.dirname(directory), {recursive: true});
fs.renameSync(original, directory);
fs.symlinkSync(path.relative(root, directory), original);
directories[name] = directory;
}
const directory = directories['markdown-it'];
const unrelated = path.join(root, '.pnpm/argparse@2.0.1/node_modules/argparse');
fs.mkdirSync(unrelated, {recursive: true});
fs.writeFileSync(path.join(unrelated, 'package.json'), '{"name":"argparse","version":"2.0.1"}\n');
const argparseLink = path.join(path.dirname(directory), 'argparse');
fs.symlinkSync(path.relative(path.dirname(argparseLink), directories.argparse), argparseLink);
const linkTarget = fs.readlinkSync(argparseLink);
// The untouched advisory PoC exceeds the same deadline that the fixed package must meet.
assert.throws(() => verifyMarkdown(directory), {code: 'ETIMEDOUT'});
const badPins = structuredClone(pins);
badPins.argparse.sha512 = '0'.repeat(128);
await assert.rejects(patchMarkdownPackages(root, badPins), {code: 'ERR_ASSERTION'});
assert.equal(JSON.parse(fs.readFileSync(path.join(directories.argparse, 'package.json'))).version, pins.argparse.from);
badPins.argparse = pins.argparse;
badPins['markdown-it'].sha512 = '0'.repeat(128);
await assert.rejects(patchMarkdownPackages(root, badPins), {code: 'ERR_ASSERTION'});
assert.equal(JSON.parse(fs.readFileSync(path.join(directory, 'package.json'))).version, pins['markdown-it'].from);
await patchMarkdownPackages(root, pins);
for (const [name, pin] of Object.entries(pins)) {
assert.ok(fs.lstatSync(path.join(root, name)).isSymbolicLink());
assert.equal(JSON.parse(fs.readFileSync(path.join(root, name, 'package.json'))).version, pin.version);
}
assert.equal(fs.readlinkSync(argparseLink), linkTarget);
assert.equal(JSON.parse(fs.readFileSync(path.join(unrelated, 'package.json'))).version, '2.0.1');
await patchMarkdownPackages(root, pins); // Verify repaired packages without another download.
const outside = path.join(temporary, 'outside');
fs.renameSync(directory, outside);
fs.symlinkSync(outside, directory);
await assert.rejects(patchMarkdownPackages(root, pins), /unexpected Markdown dependency path/);
} finally {
fs.rmSync(temporary, {recursive: true});
}
});