diff --git a/lib/marked.js b/lib/marked.js index 74424dab38..e247e978eb 100644 --- a/lib/marked.js +++ b/lib/marked.js @@ -518,7 +518,13 @@ inline.tag = edit(inline.tag) inline._punctuation = '!"#$%&\'()*+,\\-./:;<=>?@\\[^_{|}~'; inline.em = edit(inline.em).replace(/punctuation/g, inline._punctuation).getRegex(); -inline._inside = /(?:\[[^\]]*\]|[^\[\]]|\](?=[^\[]*\]))*/; +// The bracket-pair branch uses [^\[\]]* (not [^\]]*) so a single pair cannot +// itself contain '[' . Allowing '[' there made this branch overlap the outer +// repetition, causing catastrophic backtracking on nested brackets such as +// "[[[[...x...]]]]" (a ~4KB message froze the parser for seconds). Restricting +// it removes the ambiguity while still allowing one level of [..] nesting in +// link text and preserving the stray-']' case (see nested_square_link test). +inline._inside = /(?:\[[^\[\]]*\]|\](?=[^\[]*\])|[^\[\]])*/; inline._href = /(?:[^()]|\([^()]*\)|\((?:[^()]*\([^()]*\))+[^()]*\))*/; inline.link = replace(inline.link) ('inside', inline._inside) diff --git a/test/redos.js b/test/redos.js new file mode 100644 index 0000000000..605cad247d --- /dev/null +++ b/test/redos.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +/** + * Regression test for catastrophic backtracking (ReDoS) in the inline + * link/reflink "_inside" grammar. + * + * Before the fix, a single ~4 KB message of balanced nested brackets + * ("[[[[ ... x ... ]]]]") blocked the parser for ~2.6s, scaling + * super-linearly (8 KB -> ~20s). marked runs synchronously on the + * render thread in consumers such as the Mattermost web/desktop client, + * so one such chat message froze every viewer's UI. + * + * This asserts the same payloads now render well under a strict time + * budget. Run with: `node test/redos.js` + */ + +var assert = require('assert'); +var marked = require('../'); + +// Options used by the Mattermost webapp consumer. +var options = { sanitize: true, gfm: true, tables: true, mangle: false }; + +var BUDGET_MS = 250; + +var cases = [ + ['balanced nested brackets (4 KB)', + '['.repeat(2000) + 'x' + ']'.repeat(2000) + '(u)'], + ['balanced nested brackets (8 KB)', + '['.repeat(4000) + 'x' + ']'.repeat(4000) + '(u)'], + ['reflink form', + '['.repeat(3000) + 'x' + ']'.repeat(3000) + '][1]'], + ['"a][" alternation', + '[' + 'a]['.repeat(3000) + '](u)'], + ['repeated nested pairs', + '[a[b]'.repeat(2000) + '](u)'] +]; + +var failed = 0; +cases.forEach(function(c) { + var start = Date.now(); + marked(c[1], options); + var elapsed = Date.now() - start; + var ok = elapsed < BUDGET_MS; + console.log((ok ? 'ok ' : 'FAIL ') + c[0] + ' (' + c[1].length + + ' chars) -> ' + elapsed + ' ms'); + if (!ok) failed++; +}); + +assert.strictEqual(failed, 0, + failed + ' ReDoS case(s) exceeded the ' + BUDGET_MS + 'ms budget'); +console.log('\nAll ReDoS regression cases rendered within ' + BUDGET_MS + 'ms.'); diff --git a/test/tests/mm_redos_nested_brackets.html b/test/tests/mm_redos_nested_brackets.html new file mode 100644 index 0000000000..2f90a1f75c --- /dev/null +++ b/test/tests/mm_redos_nested_brackets.html @@ -0,0 +1 @@ +

outer [inner] text and [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] trailing

diff --git a/test/tests/mm_redos_nested_brackets.text b/test/tests/mm_redos_nested_brackets.text new file mode 100644 index 0000000000..a8a471f4c1 --- /dev/null +++ b/test/tests/mm_redos_nested_brackets.text @@ -0,0 +1 @@ +[outer [inner] text](http://example.com) and [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] trailing