diff --git a/subprojects/groovy-markdown/src/main/java/groovy/markdown/MarkdownSlurper.java b/subprojects/groovy-markdown/src/main/java/groovy/markdown/MarkdownSlurper.java index 65ba49e54dd..6bc0770fcb9 100644 --- a/subprojects/groovy-markdown/src/main/java/groovy/markdown/MarkdownSlurper.java +++ b/subprojects/groovy-markdown/src/main/java/groovy/markdown/MarkdownSlurper.java @@ -77,8 +77,41 @@ public class MarkdownSlurper { private static final String TABLES_EXT_CLASS = "org.commonmark.ext.gfm.tables.TablesExtension"; + /** + * Default maximum nesting depth of block/inline elements accepted before a + * {@link MarkdownRuntimeException} is thrown. Matches the default nesting cap of the + * sibling {@code JsonSlurper}. + */ + public static final int DEFAULT_MAX_NESTING_DEPTH = 1000; + private boolean tablesEnabled; + /** + * Maximum nesting depth permitted while converting a parsed document. A value of {@code 0} + * or less disables the check. Defaults to {@link #DEFAULT_MAX_NESTING_DEPTH}, overridable + * globally via the {@code groovy.markdown.maxNestingDepth} system property. + */ + private int maxNestingDepth = Integer.getInteger("groovy.markdown.maxNestingDepth", DEFAULT_MAX_NESTING_DEPTH); + + /** + * Returns the maximum nesting depth of block/inline elements the parser will accept. + * + * @return the maximum nesting depth, or a value {@code <= 0} when the check is disabled + */ + public int getMaxNestingDepth() { + return maxNestingDepth; + } + + /** + * Sets the maximum nesting depth of block/inline elements the parser will accept before + * throwing a {@link MarkdownRuntimeException}. A value of {@code 0} or less disables the check. + * + * @param maxNestingDepth maximum number of nested elements to allow + */ + public void setMaxNestingDepth(int maxNestingDepth) { + this.maxNestingDepth = maxNestingDepth; + } + /** * Enable GFM-style tables. Requires {@code commonmark-ext-gfm-tables} on the classpath. * @@ -122,6 +155,7 @@ public MarkdownDocument parseText(String md) { public MarkdownDocument parse(Reader reader) { try { Node doc = buildParser().parseReader(reader); + checkNestingDepth(doc); return new MarkdownDocument(blocksToList(doc)); } catch (IOException e) { throw new MarkdownRuntimeException(e); @@ -171,6 +205,39 @@ private Parser buildParser() { return b.build(); } + /** + * Verifies that no node in the parsed tree is nested deeper than {@link #maxNestingDepth}. + * The walk that builds the result ({@link #blocksToList}/{@link #nodeToMap}, and the text + * extraction in {@link #appendText}) recurses once per nesting level, so a small but deeply + * nested document (thousands of nested block quotes or list items) would overflow the stack. + * This iterative pre-check fails fast with a {@link MarkdownRuntimeException} instead, mirroring + * the nesting-depth cap of the sibling {@code JsonSlurper}. + * + * @param root the root node of the parsed document + */ + private void checkNestingDepth(Node root) { + if (maxNestingDepth <= 0) { + return; + } + List nodes = new ArrayList<>(); + List depths = new ArrayList<>(); + for (Node child = root.getFirstChild(); child != null; child = child.getNext()) { + nodes.add(child); + depths.add(1); + } + while (!nodes.isEmpty()) { + Node node = nodes.remove(nodes.size() - 1); + int depth = depths.remove(depths.size() - 1); + if (depth > maxNestingDepth) { + throw new MarkdownRuntimeException("Maximum Markdown nesting depth of " + maxNestingDepth + " exceeded"); + } + for (Node child = node.getFirstChild(); child != null; child = child.getNext()) { + nodes.add(child); + depths.add(depth + 1); + } + } + } + private List> blocksToList(Node parent) { List> result = new ArrayList<>(); for (Node n = parent.getFirstChild(); n != null; n = n.getNext()) { diff --git a/subprojects/groovy-markdown/src/spec/test/groovy/markdown/MarkdownSlurperTest.groovy b/subprojects/groovy-markdown/src/spec/test/groovy/markdown/MarkdownSlurperTest.groovy index 3ff87d3001e..74a9c5024e6 100644 --- a/subprojects/groovy-markdown/src/spec/test/groovy/markdown/MarkdownSlurperTest.groovy +++ b/subprojects/groovy-markdown/src/spec/test/groovy/markdown/MarkdownSlurperTest.groovy @@ -20,6 +20,8 @@ package groovy.markdown import org.junit.jupiter.api.Test +import static org.junit.jupiter.api.Assertions.assertThrows + class MarkdownSlurperTest { @Test @@ -153,4 +155,23 @@ class MarkdownSlurperTest { def doc = new MarkdownSlurper().parseText(md) assert doc.tables.isEmpty() } + + @Test + void testDeeplyNestedInputRejected() { + // a small (~5 KB) document nests block quotes thousands deep; the recursive + // document walk must not be driven into a StackOverflowError + def md = ('>' * 5000) + ' x' + def ex = assertThrows(MarkdownRuntimeException) { new MarkdownSlurper().parseText(md) } + assert ex.message.contains('nesting depth') + } + + @Test + void testMaxNestingDepthConfigurable() { + def slurper = new MarkdownSlurper() + assert slurper.maxNestingDepth == MarkdownSlurper.DEFAULT_MAX_NESTING_DEPTH + slurper.maxNestingDepth = 3 + assertThrows(MarkdownRuntimeException) { slurper.parseText(('>' * 10) + ' x') } + // input within the limit still parses + assert slurper.parseText('> quoted').nodes[0].type == 'block_quote' + } }