openvibers/openvibe.publishing Commits

openvibe-publishing 0.2.1: fix ReDoS in ssr.renderMarkdown/markdownToText (headings with trailing spaces were cubic, unclosed **/__/~~ and backtick runs quadratic, fence lines and [ runs in markdownToText quadratic); same output, linear-time matching

OP OpenVibers committed 23 Sept 2026 · c7d1138b96 · 7 files, +146 −18


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 23800fa..96b92de 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,22 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
++## 0.2.1 — 2026-09-23
++
++Security fix (no API change).
++
++### Fixed
++- **ReDoS in `ssr.renderMarkdown` and `ssr.markdownToText`.** Several patterns backtracked
++ super-linearly on untrusted Markdown, blocking the event loop on every render of the page:
++ a heading line with a long run of trailing spaces (`# a` + 4,000 spaces + `x` took 28 s, cubic),
++ unclosed `**`/`__`/`~~` openers (200 KB took 8 s each), backtick runs of rising length (26 s),
++ a fence line with trailing spaces before a non-word character (quadratic), and `[` runs in
++ `markdownToText` (quadratic). Headings, fences, emphasis and code spans are now matched in
++ linear (code spans O(n log n)) time with the same output (checked by differential fuzzing
++ against 0.2.0). One visible change: `markdownToText` no longer treats a `[` inside link text as
++ part of it (`[a [b](u) c` → `[a b c`, was `a [b c`). Products that render user Markdown with
++ this module should move to 0.2.1.
++
## 0.2.0 — 2026-09-22
**Breaking: `index-hooks` now emits the released `search.index-document@1`** (openvibe-contracts
ddiff --git a/README.md b/README.md
iindex 68f28e7..ca20088 100644
---- a/README.md
++++ b/README.md
@@@ -3,9 +3,9 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
--**Status:** alpha, 0.2.0 (roadmap Wave 15). The modules are tested and the exit proof
--(`examples/two-products`) runs in `npm test`. v0.1.0 is tagged; 0.2.0 (the released Search
--document shape, a breaking change — see [CHANGELOG.md](CHANGELOG.md)) is not tagged yet. No product uses them in production yet: Wiki and Blog (Wave 16) are the
++**Status:** alpha, 0.2.1 (roadmap Wave 15). The modules are tested and the exit proof
++(`examples/two-products`) runs in `npm test`. v0.1.0 and v0.2.0 are tagged; 0.2.1 (a Markdown
++ReDoS fix — see [CHANGELOG.md](CHANGELOG.md)) is not tagged yet. No product uses them in production yet: Wiki and Blog (Wave 16) are the
first planned consumers.
**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).
**License:** MIT, like OpenVibe.Shared.
ddiff --git a/STATUS.json b/STATUS.json
iindex 0b1e918..d9cab32 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"wave": "W15",
"kind": "packages",
"package": "openvibe-publishing",
-- "version": "0.2.0",
++ "version": "0.2.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"port": null,
ddiff --git a/lib/ssr.js b/lib/ssr.js
iindex 1c98446..f3a5983 100644
---- a/lib/ssr.js
++++ b/lib/ssr.js
@@@ -47,8 +47,10 @@ function html(strings, ...values) {
// ---- Markdown ----------------------------------------------------------------------------------
const MAX_SOURCE = 200000;
--const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})\s*$/;
--const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
++// Every pattern here runs on untrusted text on each page view, so none may backtrack
++// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.
++const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;
++const HEADING_RE = /^ {0,3}(#{1,6})\s+([\s\S]*)$/;
const QUOTE = /^ {0,3}>\s?(.*)$/;
const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
const UL = /^ {0,3}[-*+]\s+(.*)$/;
@@@ -59,20 +61,99 @@ function decodeBasic(s) {
return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));
}
++const FENCE = { test: (line) => FENCE_RE.test(line.trimEnd()) };
++function fenceMatch(line) { return line.trimEnd().match(FENCE_RE); }
++
++/** [, hashes, text] for an ATX heading (text without the optional closing #s), or null. */
++function headingMatch(line) {
++ const m = line.match(HEADING_RE);
++ if (!m) return null;
++ const text = m[2].trimEnd().replace(/#+$/, '').trimEnd();
++ return /[\u2028\u2029]/.test(text) ? null : [m[0], m[1], text];
++}
++const HEADING = { test: (line) => headingMatch(line) !== null };
++
++/**
++ * Same result as s.replace(/<open>([\s\S]*?\S)<close>/g, …) for a delimiter pair, in linear time:
++ * the lazy regex rescans to the end of the text for every opener that has no closer (quadratic).
++ * Content runs from the end of an opener to the first closer after it; once one opener finds no
++ * closer, no later opener can either, so the scan stops.
++ */
++function pairUp(s, open, close, wrap) {
++ const o = new RegExp(open.source, 'g');
++ const c = new RegExp(close.source, 'g');
++ let out = '';
++ let pos = 0;
++ for (;;) {
++ o.lastIndex = pos;
++ const m = o.exec(s);
++ if (!m) break;
++ const start = m.index + m[0].length;
++ c.lastIndex = start;
++ const k = c.exec(s);
++ if (!k) break;
++ const end = k.index + 1; // the closer pattern starts with the content's last (non-space) character
++ out += s.slice(pos, m.index) + wrap(m, s.slice(start, end));
++ pos = k.index + k[0].length;
++ }
++ return out + s.slice(pos);
++}
++
function emphasis(s) {
-- return s
-- .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
-- .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<strong>$2</strong>')
++ s = pairUp(s, /\*\*(?=\S)/, /\S\*\*/, (_m, t) => `<strong>${t}</strong>`);
++ s = pairUp(s, /(^|[^\w])__(?=\S)/, /\S__(?!\w)/, (m, t) => `${m[1]}<strong>${t}</strong>`);
++ s = s
.replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
-- .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
-- .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
++ .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>');
++ return pairUp(s, /~~(?=\S)/, /\S~~/, (_m, t) => `<del>${t}</del>`);
++}
++
++/**
++ * Same result as s.replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, …) in O(n log n): an opener
++ * is the tail of a backtick run (longest first, as the regex tries it), its closer the next whole
++ * run of exactly that length not preceded by a newline, with non-empty content not starting with one.
++ */
++function codeSpans(s, onCode) {
++ const runs = [];
++ const byLen = new Map();
++ const re = /`+/g;
++ let m;
++ while ((m = re.exec(s))) {
++ runs.push([m.index, m[0].length]);
++ if (s[m.index - 1] !== '\n') {
++ if (!byLen.has(m[0].length)) byLen.set(m[0].length, []);
++ byLen.get(m[0].length).push(m.index);
++ }
++ }
++ const nextAt = (list, from) => {
++ let lo = 0;
++ let hi = list.length;
++ while (lo < hi) { const mid = (lo + hi) >> 1; if (list[mid] < from) lo = mid + 1; else hi = mid; }
++ return lo < list.length ? list[lo] : -1;
++ };
++ let out = '';
++ let pos = 0;
++ for (const [at, len] of runs) {
++ if (at < pos) continue;
++ const contentStart = at + len;
++ if (contentStart >= s.length || s[contentStart] === '\n') continue;
++ for (let n = len; n >= 1; n--) {
++ const list = byLen.get(n);
++ const close = list ? nextAt(list, contentStart + 1) : -1;
++ if (close < 0) continue;
++ out += s.slice(pos, at + len - n) + onCode(s.slice(contentStart, close));
++ pos = close + n;
++ break;
++ }
++ }
++ return out + s.slice(pos);
}
function inline(src, opts) {
const held = [];
const hold = (h) => `\u0000${held.push(h) - 1}\u0000`;
const relAttr = opts.rel ? ` rel="${escapeHtml(opts.rel)}"` : '';
-- let s = String(src).replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, (_m, _t, code) => hold(`<code>${escapeHtml(code)}</code>`));
++ let s = codeSpans(String(src), (code) => hold(`<code>${escapeHtml(code)}</code>`));
s = escapeHtml(s);
s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
const href = decodeBasic(url);
@@@ -107,7 +188,7 @@ function blocks(lines, depth, opts) {
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
-- let m = line.match(FENCE);
++ let m = fenceMatch(line);
if (m) {
const close = new RegExp(`^ {0,3}\\${m[1][0]}{${m[1].length},}\\s*$`);
const body = [];
@@@ -119,7 +200,7 @@ function blocks(lines, depth, opts) {
continue;
}
if (HR.test(line)) { out.push('<hr>'); i++; continue; }
-- m = line.match(HEADING);
++ m = headingMatch(line);
if (m) {
const level = Math.min(Math.max(m[1].length + opts.headingShift, 2), 6);
out.push(`<h${level}>${inline(m[2], opts)}</h${level}>`);
@@@ -168,7 +249,7 @@ function renderMarkdown(source, { rel = 'nofollow ugc noopener', headingShift =
function markdownToText(source, max = Infinity) {
const t = String(source == null ? '' : source).replace(/\u0000/g, '')
.replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
-- .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1')
++ .replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1')
.replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
.replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, '')
.replace(/(\*\*|__|~~|`)/g, '')
ddiff --git a/package-lock.json b/package-lock.json
iindex dccddbc..2840e7f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-publishing",
-- "version": "0.2.0",
++ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-publishing",
-- "version": "0.2.0",
++ "version": "0.2.1",
"license": "MIT",
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
ddiff --git a/package.json b/package.json
iindex 90518f5..d834005 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-publishing",
-- "version": "0.2.0",
++ "version": "0.2.1",
"description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
"main": "index.js",
"exports": {
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex f67b694..81b7877 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -37,6 +37,37 @@ test('markdown: the supported subset renders; raw HTML and unsafe links do not',
assert.doesNotMatch(ssr.renderMarkdown('[x](https://a.test/"onmouseover="alert(1))'), /" onmouseover|"onmouseover="alert/);
});
++test('markdown: pathological input renders in linear time (no ReDoS)', () => {
++ let codeRuns = '';
++ for (let k = 1; codeRuns.length < 60000; k++) codeRuns += `a${'`'.repeat(k)}`;
++ const cases = {
++ heading: `# a${' '.repeat(6000)}x`,
++ fence: `\`\`\`${' '.repeat(60000)}!`,
++ strong: '**x '.repeat(40000),
++ underscore: ' __x'.repeat(40000),
++ strike: '~~x '.repeat(40000),
++ codeRuns,
++ };
++ for (const [name, src] of Object.entries(cases)) {
++ const started = Date.now();
++ ssr.renderMarkdown(src);
++ const ms = Date.now() - started;
++ assert.ok(ms < 1000, `renderMarkdown ${name} took ${ms}ms`);
++ }
++ const started = Date.now();
++ ssr.markdownToText('['.repeat(100000));
++ assert.ok(Date.now() - started < 1000, `markdownToText took ${Date.now() - started}ms`);
++});
++
++test('markdown: code spans, emphasis and headings keep their meaning', () => {
++ assert.strictEqual(ssr.renderMarkdown('# Title ## '), '<h2>Title</h2>');
++ assert.strictEqual(ssr.renderMarkdown('## a # b #'), '<h3>a # b</h3>');
++ assert.strictEqual(ssr.renderMarkdown('``` js \nx\n```'), '<pre><code class="language-js">x</code></pre>');
++ assert.strictEqual(ssr.renderMarkdown('``a`b`` and `c` ``d`'), '<p><code>a`b</code> and <code>c</code> `<code>d</code></p>');
++ assert.strictEqual(ssr.renderMarkdown('**a** **b **c** ~~d~~ x__y__ __z__'), '<p><strong>a</strong> <strong>b **c</strong> <del>d</del> x__y__ <strong>z</strong></p>');
++ assert.strictEqual(ssr.markdownToText('[a [b](https://x.test) c'), '[a b c');
++});
++
test('markdownToText and wordCount', () => {
assert.strictEqual(ssr.markdownToText('# Hi\n\nSome **bold** [link](https://x.test).\n\n```\ncode\n```'), 'Hi Some bold link. code');
assert.strictEqual(ssr.markdownToText('one two three four', 10), 'one two…');