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
Changes
@@@ -4,6 +4,22 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://sbreaking change to any exported function, table layout, reason code or document shape is a newmajor (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@@@ -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 thefirst planned consumers.**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).**License:** MIT, like OpenVibe.Shared.@@@ -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,@@@ -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, '')@@@ -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"@@@ -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": {@@@ -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…');
@@ -4,6 +4,22 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -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
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.
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
## 0.2.0 — 2026-09-22
**Breaking: `index-hooks` now emits the released `search.index-document@1`** (openvibe-contracts
**Breaking: `index-hooks` now emits the released `search.index-document@1`** (openvibe-contracts
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
> 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.
first planned consumers.
**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).
**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).
**License:** MIT, like OpenVibe.Shared.
**License:** MIT, like OpenVibe.Shared.
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"wave": "W15",
"wave": "W15",
"kind": "packages",
"kind": "packages",
"package": "openvibe-publishing",
"package": "openvibe-publishing",
- "version": "0.2.0",
+ "version": "0.2.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"runtime": null,
"port": null,
"port": null,
@@ -47,8 +47,10 @@ function html(strings, ...values) {
@@ -47,8 +47,10 @@ function html(strings, ...values) {
// ---- Markdown ----------------------------------------------------------------------------------
// ---- Markdown ----------------------------------------------------------------------------------
const MAX_SOURCE = 200000;
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 QUOTE = /^ {0,3}>\s?(.*)$/;
const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
const UL = /^ {0,3}[-*+]\s+(.*)$/;
const UL = /^ {0,3}[-*+]\s+(.*)$/;
@@ -59,20 +61,99 @@ function decodeBasic(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]));
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) {
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(/(^|[^\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) {
function inline(src, opts) {
const held = [];
const held = [];
const hold = (h) => `\u0000${held.push(h) - 1}\u0000`;
const hold = (h) => `\u0000${held.push(h) - 1}\u0000`;
const relAttr = opts.rel ? ` rel="${escapeHtml(opts.rel)}"` : '';
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 = escapeHtml(s);
s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
const href = decodeBasic(url);
const href = decodeBasic(url);
@@ -107,7 +188,7 @@ function blocks(lines, depth, opts) {
@@ -107,7 +188,7 @@ function blocks(lines, depth, opts) {
while (i < lines.length) {
while (i < lines.length) {
const line = lines[i];
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (!line.trim()) { i++; continue; }
- let m = line.match(FENCE);
+ let m = fenceMatch(line);
if (m) {
if (m) {
const close = new RegExp(`^ {0,3}\\${m[1][0]}{${m[1].length},}\\s*$`);
const close = new RegExp(`^ {0,3}\\${m[1][0]}{${m[1].length},}\\s*$`);
const body = [];
const body = [];
@@ -119,7 +200,7 @@ function blocks(lines, depth, opts) {
@@ -119,7 +200,7 @@ function blocks(lines, depth, opts) {
continue;
continue;
}
}
if (HR.test(line)) { out.push('<hr>'); i++; continue; }
if (HR.test(line)) { out.push('<hr>'); i++; continue; }
- m = line.match(HEADING);
+ m = headingMatch(line);
if (m) {
if (m) {
const level = Math.min(Math.max(m[1].length + opts.headingShift, 2), 6);
const level = Math.min(Math.max(m[1].length + opts.headingShift, 2), 6);
out.push(`<h${level}>${inline(m[2], opts)}</h${level}>`);
out.push(`<h${level}>${inline(m[2], opts)}</h${level}>`);
@@ -168,7 +249,7 @@ function renderMarkdown(source, { rel = 'nofollow ugc noopener', headingShift =
@@ -168,7 +249,7 @@ function renderMarkdown(source, { rel = 'nofollow ugc noopener', headingShift =
function markdownToText(source, max = Infinity) {
function markdownToText(source, max = Infinity) {
const t = String(source == null ? '' : source).replace(/\u0000/g, '')
const t = String(source == null ? '' : source).replace(/\u0000/g, '')
.replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
.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}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
.replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, '')
.replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, '')
.replace(/(\*\*|__|~~|`)/g, '')
.replace(/(\*\*|__|~~|`)/g, '')
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "0.2.0",
+ "version": "0.2.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "0.2.0",
+ "version": "0.2.1",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"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.",
"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",
"main": "index.js",
"exports": {
"exports": {
@@ -37,6 +37,37 @@ test('markdown: the supported subset renders; raw HTML and unsafe links do not',
@@ -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/);
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', () => {
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('# 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…');
assert.strictEqual(ssr.markdownToText('one two three four', 10), 'one two…');