Skip to content

Commit 55dcfb9

Browse files
committed
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.
1 parent f140841 commit 55dcfb9

3 files changed

Lines changed: 130 additions & 9 deletions

File tree

subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,24 @@ public static String encodeAngleBrackets(String text) {
12451245
return text == null ? null : text.replace("<", "&lt;").replace(">", "&gt;");
12461246
}
12471247

1248+
/**
1249+
* Escapes text for use as the value of a quoted HTML attribute, so that text taken from a
1250+
* doc comment cannot close the attribute or the tag around it.
1251+
*
1252+
* @param text the text to escape
1253+
* @return the escaped text
1254+
*/
1255+
public static String encodeAttribute(String text) {
1256+
if (text == null) return null;
1257+
// The ampersand is replaced first so that the entities introduced below are not
1258+
// themselves re-escaped.
1259+
return text.replace("&", "&amp;")
1260+
.replace("<", "&lt;")
1261+
.replace(">", "&gt;")
1262+
.replace("\"", "&quot;")
1263+
.replace("'", "&#39;");
1264+
}
1265+
12481266
/**
12491267
* Stores the rendered class name including any type arguments.
12501268
*

subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
import java.nio.file.Files;
3131
import java.nio.file.Path;
3232
import java.nio.file.Paths;
33+
34+
import groovy.util.regex.RegexGuard;
35+
import groovy.util.regex.RegexTimeoutException;
3336
import java.util.ArrayList;
3437
import java.util.HashSet;
3538
import java.util.LinkedHashMap;
@@ -100,6 +103,13 @@
100103
*/
101104
final class TagRenderer {
102105

106+
/**
107+
* How long a single snippet markup directive may spend matching one line. A directive
108+
* carries a regex written by the author of the documented source, so the budget bounds what
109+
* a doc comment can cost the build rather than trying to decide which patterns are safe.
110+
*/
111+
private static final long MATCH_TIMEOUT_MILLIS = 250L;
112+
103113
/** Block-tag names that get merged under a single display heading. */
104114
static final Map<String, String> COLLATED_TAGS = new LinkedHashMap<>();
105115
static {
@@ -450,9 +460,15 @@ private static int renderSnippetAt(String text, int start, int nameEnd, StringBu
450460
// or hyperlinks.
451461
String processed = processSnippetMarkup(dedented, links, relPath, rootDoc, classDoc);
452462
out.append("<pre><code");
453-
if (!combined.isEmpty()) out.append(" class=\"").append(combined).append('"');
463+
// The class and id values come from snippet attributes, which the parser above lets
464+
// carry a double quote when the attribute itself was single quoted or unquoted.
465+
if (!combined.isEmpty()) {
466+
out.append(" class=\"").append(SimpleGroovyClassDoc.encodeAttribute(combined)).append('"');
467+
}
454468
String id = attrs.get("id");
455-
if (id != null && !id.isEmpty()) out.append(" id=\"").append(id).append('"');
469+
if (id != null && !id.isEmpty()) {
470+
out.append(" id=\"").append(SimpleGroovyClassDoc.encodeAttribute(id)).append('"');
471+
}
456472
out.append('>').append(processed).append("</code></pre>");
457473
return endPos - start;
458474
}
@@ -759,15 +775,28 @@ private static String applyDirective(String escaped, Directive d,
759775
GroovyRootDoc rootDoc, SimpleGroovyClassDoc classDoc) {
760776
Pattern pat = buildMatchPattern(d);
761777
if (pat == null) return escaped; // no match clause — nothing to do
762-
Matcher m = pat.matcher(escaped);
778+
// Both the pattern and the line it runs against come from the documented source, so a
779+
// directive can otherwise pin the doc build on a few bytes. Give each directive a
780+
// deadline rather than trying to judge which patterns are safe.
781+
Matcher m;
782+
try {
783+
m = RegexGuard.matcher(pat, escaped, MATCH_TIMEOUT_MILLIS);
784+
} catch (RegexTimeoutException e) {
785+
return escaped;
786+
}
763787
StringBuilder sb = new StringBuilder();
764788
int last = 0;
765-
while (m.find()) {
766-
sb.append(escaped, last, m.start());
767-
String match = m.group();
768-
String wrapped = wrapForDirective(match, d, links, relPath, rootDoc, classDoc);
769-
sb.append(wrapped);
770-
last = m.end();
789+
try {
790+
while (m.find()) {
791+
sb.append(escaped, last, m.start());
792+
String match = m.group();
793+
String wrapped = wrapForDirective(match, d, links, relPath, rootDoc, classDoc);
794+
sb.append(wrapped);
795+
last = m.end();
796+
}
797+
} catch (RegexTimeoutException e) {
798+
// Leave the line as it was rather than half annotated.
799+
return escaped;
771800
}
772801
sb.append(escaped, last, escaped.length());
773802
return sb.toString();

subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,80 @@ public void testSnippetTagExternalFormLoadsFromSnippetFiles() throws Exception {
252252
snip.contains("Licensed to the Apache"));
253253
}
254254

255+
// GROOVY-12275: snippet attribute values reach quoted HTML attributes. The attribute parser
256+
// accepts a double quote inside a single-quoted or unquoted value, so an id could close the
257+
// attribute and the tag around it.
258+
public void testSnippetAttributeValuesCannotEscapeTheirAttribute() throws Exception {
259+
String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles";
260+
Path tmp = Files.createTempDirectory("snippet-attr-");
261+
Path pkgDir = tmp.resolve(pkg);
262+
Files.createDirectories(pkgDir);
263+
Files.writeString(pkgDir.resolve("SnippetAttr.groovy"),
264+
"package " + pkg.replace('/', '.') + "\n" +
265+
"/**\n" +
266+
" * {@snippet id='x\"><img src=q onerror=\"alert(1)' class='y\"><b' :\n" +
267+
" * def a = 1\n" +
268+
" * }\n" +
269+
" */\n" +
270+
"class SnippetAttr {}\n");
271+
272+
String doc = renderSingle(tmp, pkg, "SnippetAttr");
273+
assertNotNull(doc);
274+
assertTrue("the snippet should still render in:\n" + doc, doc.contains("<pre><code"));
275+
assertFalse("an attribute value closed its attribute in:\n" + doc,
276+
doc.contains("<img src=q"));
277+
assertFalse("an attribute value opened a tag in:\n" + doc, doc.contains("\"><b"));
278+
}
279+
280+
// GROOVY-12275: a markup directive's regex is written by the author of the documented
281+
// source and runs against lines they also wrote, so it must not be able to pin the build.
282+
// Note the payload: on a current JDK the textbook nested-quantifier patterns are optimised
283+
// away and finish instantly, while a backreference still backtracks exponentially.
284+
public void testSnippetMarkupRegexCannotHangTheBuild() throws Exception {
285+
String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles";
286+
Path tmp = Files.createTempDirectory("snippet-redos-");
287+
Path pkgDir = tmp.resolve(pkg);
288+
Files.createDirectories(pkgDir);
289+
String payload = "a".repeat(32);
290+
Files.writeString(pkgDir.resolve("SnippetRedos.groovy"),
291+
"package " + pkg.replace('/', '.') + "\n" +
292+
"/**\n" +
293+
" * {@snippet lang=\"groovy\" :\n" +
294+
" * " + payload + " // @highlight regex=\"(a+)+\\1b\" type=\"bold\"\n" +
295+
" * }\n" +
296+
" */\n" +
297+
"class SnippetRedos {}\n");
298+
299+
long start = System.nanoTime();
300+
String doc = renderSingle(tmp, pkg, "SnippetRedos");
301+
long elapsedMs = (System.nanoTime() - start) / 1_000_000L;
302+
303+
assertNotNull(doc);
304+
assertTrue("the snippet should still render in:\n" + doc, doc.contains("<pre><code"));
305+
assertTrue("the snippet body should survive in:\n" + doc, doc.contains(payload));
306+
// Unguarded this payload runs for minutes and grows exponentially with the line length;
307+
// guarded it is bounded per directive. The threshold is loose so the test is about the
308+
// bound existing, not about the speed of the machine.
309+
assertTrue("rendering took " + elapsedMs + "ms, so the directive regex was not bounded",
310+
elapsedMs < 30_000L);
311+
}
312+
313+
/** Renders one class from a temporary source tree and returns its page. */
314+
private String renderSingle(Path sourcePath, String pkg, String simpleName) throws Exception {
315+
GroovyDocTool tool = new GroovyDocTool(
316+
new FileSystemResourceManager("src/main/resources"),
317+
new String[]{sourcePath.toString()},
318+
GroovyDocTemplateInfo.DEFAULT_DOC_TEMPLATES,
319+
GroovyDocTemplateInfo.DEFAULT_PACKAGE_TEMPLATES,
320+
GroovyDocTemplateInfo.DEFAULT_CLASS_TEMPLATES,
321+
new ArrayList<>(), null, new Properties()
322+
);
323+
tool.add(List.of(pkg + "/" + simpleName + ".groovy"));
324+
MockOutputTool output = new MockOutputTool();
325+
tool.renderToOutput(output, MOCK_DIR);
326+
return output.getText(MOCK_DIR + "/" + pkg + "/" + simpleName + ".html");
327+
}
328+
255329
// Auto-strip opt-out: {@snippet file="X" keepHeader=true} preserves the
256330
// file content verbatim, and lang is inferred from the file's extension
257331
// when no explicit lang= is given.

0 commit comments

Comments
 (0)