diff --git a/.github/workflows/openclaw-upstream-docker.yml b/.github/workflows/openclaw-upstream-docker.yml index e43a8e0..0a2b2ac 100644 --- a/.github/workflows/openclaw-upstream-docker.yml +++ b/.github/workflows/openclaw-upstream-docker.yml @@ -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 diff --git a/openclaw-builder/configs/components.json b/openclaw-builder/configs/components.json index 41a86e1..c5a55d4 100644 --- a/openclaw-builder/configs/components.json +++ b/openclaw-builder/configs/components.json @@ -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", diff --git a/openclaw-builder/image/patch-vendored-deps.mjs b/openclaw-builder/image/patch-vendored-deps.mjs index 5e8dd51..b781646 100644 --- a/openclaw-builder/image/patch-vendored-deps.mjs +++ b/openclaw-builder/image/patch-vendored-deps.mjs @@ -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**'), '

safe

\\n'); + assert.equal(md.render('[a]: /url "title"\\n\\n[a]'), + '

a

\\n'); + assert.equal(md.render('[a]: /url "open\\nclose"\\n\\n[a]'), + '

a

\\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') : []) { diff --git a/scripts/test-openclaw-markdown.mjs b/scripts/test-openclaw-markdown.mjs new file mode 100644 index 0000000..7e81379 --- /dev/null +++ b/scripts/test-openclaw-markdown.mjs @@ -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}); + } +});