From 0a0bf52b365023c881651b50d7433be81cca05eb Mon Sep 17 00:00:00 2001 From: Paul King Date: Tue, 18 Aug 2026 19:51:29 +1000 Subject: [PATCH] GROOVY-12275: Encode snippet attribute values and bound snippet markup regexes Two defects in the same {@snippet} handling, both reached from a doc comment in the source being documented. The class and id attributes were appended to the generated element without encoding. The attribute parser accepts a double quote inside a value which was single quoted or unquoted, so a value could close its attribute and the tag around it. Encode both through a new SimpleGroovyClassDoc.encodeAttribute, which escapes the ampersand first and then the characters that can end an attribute or start a tag. The snippet body was already escaped; this brings the attributes up to the same standard. A markup directive's regex attribute was compiled and run against snippet lines with no bound. Give each directive a deadline using RegexGuard, and leave the line unannotated rather than half annotated if it expires. The payload in the test is worth a note. The finding cites (a+)+$ against a long run of characters, and on a current JDK that is not slow: the textbook nested-quantifier patterns, (a+)+b, (a|aa)+$, (x+x+)+y and (a*)*b among them, all complete in about a millisecond, because the engine recognises them. A backreference still backtracks exponentially. Measured with the guard removed, a directive carrying (a+)+\1b against a 32 character line took 152 seconds to render one page, and grows exponentially with the line; with the guard the same page renders in well under a second. So the finding is right that the risk exists and wrong about how it is reached, and a test built on its own example would have passed with or without a fix. --- .../tools/groovydoc/SimpleGroovyClassDoc.java | 18 +++++ .../groovy/tools/groovydoc/TagRenderer.java | 47 +++++++++--- .../tools/groovydoc/GroovyDocToolTest.java | 74 +++++++++++++++++++ 3 files changed, 130 insertions(+), 9 deletions(-) diff --git a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java index 9abe695421c..b42c1d46456 100644 --- a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java +++ b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java @@ -1245,6 +1245,24 @@ public static String encodeAngleBrackets(String text) { return text == null ? null : text.replace("<", "<").replace(">", ">"); } + /** + * Escapes text for use as the value of a quoted HTML attribute, so that text taken from a + * doc comment cannot close the attribute or the tag around it. + * + * @param text the text to escape + * @return the escaped text + */ + public static String encodeAttribute(String text) { + if (text == null) return null; + // The ampersand is replaced first so that the entities introduced below are not + // themselves re-escaped. + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + /** * Stores the rendered class name including any type arguments. * diff --git a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java index 8f7cfa6a7da..064a6db5a5b 100644 --- a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java +++ b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java @@ -30,6 +30,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; + +import groovy.util.regex.RegexGuard; +import groovy.util.regex.RegexTimeoutException; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; @@ -100,6 +103,13 @@ */ final class TagRenderer { + /** + * How long a single snippet markup directive may spend matching one line. A directive + * carries a regex written by the author of the documented source, so the budget bounds what + * a doc comment can cost the build rather than trying to decide which patterns are safe. + */ + private static final long MATCH_TIMEOUT_MILLIS = 250L; + /** Block-tag names that get merged under a single display heading. */ static final Map COLLATED_TAGS = new LinkedHashMap<>(); static { @@ -450,9 +460,15 @@ private static int renderSnippetAt(String text, int start, int nameEnd, StringBu // or hyperlinks. String processed = processSnippetMarkup(dedented, links, relPath, rootDoc, classDoc); out.append("
').append(processed).append("
"); return endPos - start; } @@ -759,15 +775,28 @@ private static String applyDirective(String escaped, Directive d, GroovyRootDoc rootDoc, SimpleGroovyClassDoc classDoc) { Pattern pat = buildMatchPattern(d); if (pat == null) return escaped; // no match clause — nothing to do - Matcher m = pat.matcher(escaped); + // Both the pattern and the line it runs against come from the documented source, so a + // directive can otherwise pin the doc build on a few bytes. Give each directive a + // deadline rather than trying to judge which patterns are safe. + Matcher m; + try { + m = RegexGuard.matcher(pat, escaped, MATCH_TIMEOUT_MILLIS); + } catch (RegexTimeoutException e) { + return escaped; + } StringBuilder sb = new StringBuilder(); int last = 0; - while (m.find()) { - sb.append(escaped, last, m.start()); - String match = m.group(); - String wrapped = wrapForDirective(match, d, links, relPath, rootDoc, classDoc); - sb.append(wrapped); - last = m.end(); + try { + while (m.find()) { + sb.append(escaped, last, m.start()); + String match = m.group(); + String wrapped = wrapForDirective(match, d, links, relPath, rootDoc, classDoc); + sb.append(wrapped); + last = m.end(); + } + } catch (RegexTimeoutException e) { + // Leave the line as it was rather than half annotated. + return escaped; } sb.append(escaped, last, escaped.length()); return sb.toString(); diff --git a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java index 76f648a3c27..60566d00d69 100644 --- a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java +++ b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java @@ -252,6 +252,80 @@ public void testSnippetTagExternalFormLoadsFromSnippetFiles() throws Exception { snip.contains("Licensed to the Apache")); } + // GROOVY-12275: snippet attribute values reach quoted HTML attributes. The attribute parser + // accepts a double quote inside a single-quoted or unquoted value, so an id could close the + // attribute and the tag around it. + public void testSnippetAttributeValuesCannotEscapeTheirAttribute() throws Exception { + String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles"; + Path tmp = Files.createTempDirectory("snippet-attr-"); + Path pkgDir = tmp.resolve(pkg); + Files.createDirectories(pkgDir); + Files.writeString(pkgDir.resolve("SnippetAttr.groovy"), + "package " + pkg.replace('/', '.') + "\n" + + "/**\n" + + " * {@snippet id='x\">(), null, new Properties() + ); + tool.add(List.of(pkg + "/" + simpleName + ".groovy")); + MockOutputTool output = new MockOutputTool(); + tool.renderToOutput(output, MOCK_DIR); + return output.getText(MOCK_DIR + "/" + pkg + "/" + simpleName + ".html"); + } + // Auto-strip opt-out: {@snippet file="X" keepHeader=true} preserves the // file content verbatim, and lang is inferred from the file's extension // when no explicit lang= is given.