Skip to content

Commit 0686a9f

Browse files
committed
GROOVY-12277: Encode source-derived text emitted into generated HTML
Two places where groovydoc builds HTML around text taken from the source it is documenting, without encoding it for the context it lands in. A {@link} or @see reference is split into a target and a label, both of which are then concatenated into an anchor the tool constructs: the target into href, the label into the element text, and for a resolved class the short name into title as well. None was encoded, so a reference could close the attribute and open a tag of its own. Encode each for its context, the attributes through encodeAttribute and the text through encodeAngleBrackets. An annotation's name and description are emitted into the class declaration and every member heading. description() carries the annotation's arguments as they were written, so an annotation holding a string literal put that literal into the page verbatim. Encode both, leaving the linkable() call alone since that one does produce markup. This is groovydoc's own construction rather than the raw HTML a doc comment body may contain by javadoc parity, so the passthrough that covers a comment body does not extend to it. For the annotation case the text is not from a comment at all: it is source code, and reaches the page without any doc comment being written. Not changed: the @default tag GroovydocJavaVisitor appends for an annotation member's default value. It is added to the raw comment text and no template renders it as a declaration, and constantValueExpression() is only tested for nullity, so that value does not reach a declaration block.
1 parent 34ed575 commit 0686a9f

3 files changed

Lines changed: 85 additions & 3 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,10 @@ else if (ch == ',' && nested == 0) {
658658
}
659659

660660
if (type.startsWith("#"))
661-
return "<a href='" + resolveMethodArgs(rootDoc, classDoc, type) + "'>" + (label == null ? type.substring(1) : label) + "</a>";
661+
// The target and label both come from the doc comment, so they are encoded for the
662+
// context each lands in: the href is an attribute value, the label is element text.
663+
return "<a href='" + encodeAttribute(resolveMethodArgs(rootDoc, classDoc, type)) + "'>"
664+
+ encodeAngleBrackets(label == null ? type.substring(1) : label) + "</a>";
662665

663666
if (type.endsWith("[]")) {
664667
String componentType = type.substring(0, type.length() - 2);
@@ -738,7 +741,8 @@ private static String buildUrl(String relativeRoot, String[] target, String shor
738741
? target[0].replace('$', '.')
739742
: target[0].replace('.', '/').replace('$', '.');
740743
String url = relativeRoot + targetPath + ".html" + (target.length > 1 ? "#" + target[1] : "");
741-
return "<a href='" + url + "' title='" + shortClassName + "'>" + shortClassName + "</a>";
744+
return "<a href='" + encodeAttribute(url) + "' title='" + encodeAttribute(shortClassName) + "'>"
745+
+ encodeAngleBrackets(shortClassName) + "</a>";
742746
}
743747

744748
private GroovyClassDoc resolveClass(GroovyRootDoc rootDoc, String name) {

subprojects/groovy-groovydoc/src/main/resources/org/codehaus/groovy/tools/groovydoc/gstringTemplates/classLevel/classDocName.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,10 @@
7272
(t.isStatic()?"static&nbsp;":"")
7373
}
7474
def annotations = { t, sepChar ->
75-
t.annotations() ? t.annotations().collect{ it.isTypeAvailable()?'@'+linkable(it.type().typeName())+it.description():'@'+it.name()+it.description()}.join(sepChar) + sepChar : ''
75+
// An annotation's name and description are source text, not markup: description()
76+
// re-embeds the annotation's arguments verbatim. Only linkable() produces HTML here.
77+
def encode = { org.codehaus.groovy.tools.groovydoc.SimpleGroovyClassDoc.encodeAngleBrackets(it ?: '') }
78+
t.annotations() ? t.annotations().collect{ it.isTypeAvailable()?'@'+linkable(it.type().typeName())+encode(it.description()):'@'+encode(it.name())+encode(it.description())}.join(sepChar) + sepChar : ''
7679
}
7780
def elementTypes = [
7881
"required":"true",

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,81 @@ private String renderSingle(Path sourcePath, String pkg, String simpleName) thro
385385
return output.getText(MOCK_DIR + "/" + pkg + "/" + simpleName + ".html");
386386
}
387387

388+
// GROOVY-12277: a {@link} label and target are doc-comment text that groovydoc puts into
389+
// the href and title of an anchor it builds itself, so they must be encoded for those
390+
// contexts. This is groovydoc's own construction, not the documented raw-HTML passthrough
391+
// of a comment body.
392+
public void testLinkTagCannotBreakOutOfTheAnchorItBuilds() throws Exception {
393+
String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles";
394+
Path tmp = Files.createTempDirectory("linktag-");
395+
Path pkgDir = tmp.resolve(pkg);
396+
Files.createDirectories(pkgDir);
397+
Files.writeString(pkgDir.resolve("Helper.groovy"),
398+
"package " + pkg.replace('/', '.') + "\nclass Helper { void go() {} }\n");
399+
// The label of the resolved-class link ('Helper ...') lands in the title attribute of the
400+
// anchor buildUrl constructs; the target of the method link ('#go(...)') lands in its href.
401+
// Each payload closes its ' delimiter, so if written unencoded the title/href ends early
402+
// and the trailing text becomes a live attribute of the anchor.
403+
Files.writeString(pkgDir.resolve("LinkTag.groovy"),
404+
"package " + pkg.replace('/', '.') + "\n" +
405+
"/**\n" +
406+
" * See {@link Helper TITLEPWN' onbreak='x(1)}\n" +
407+
" * and {@link #go(a' onmouseover='alert(2)) L}\n" +
408+
" */\n" +
409+
"class LinkTag {}\n");
410+
411+
// Both files must be rendered together so 'Helper' resolves and the class-link path
412+
// (buildUrl) is actually exercised; rendering LinkTag alone drops the unresolved link.
413+
String doc = renderTogether(tmp, pkg, List.of("Helper", "LinkTag"), "LinkTag");
414+
assertNotNull(doc);
415+
// A broken title reads title='TITLEPWN' onbreak=...; the encoded one keeps the quote as
416+
// &#39; so the marker is never immediately followed by a closing quote. (The label also
417+
// appears as harmless element text, so key on the title= context, not the bare marker.)
418+
assertFalse("a resolved-class link label broke out of its title attribute in:\n" + doc,
419+
doc.contains("title='TITLEPWN'"));
420+
assertFalse("a method link target broke out of its href attribute in:\n" + doc,
421+
doc.contains("onmouseover='alert"));
422+
}
423+
424+
private String renderTogether(Path sourcePath, String pkg, List<String> simpleNames, String target) throws Exception {
425+
GroovyDocTool tool = new GroovyDocTool(
426+
new FileSystemResourceManager("src/main/resources"),
427+
new String[]{sourcePath.toString()},
428+
GroovyDocTemplateInfo.DEFAULT_DOC_TEMPLATES,
429+
GroovyDocTemplateInfo.DEFAULT_PACKAGE_TEMPLATES,
430+
GroovyDocTemplateInfo.DEFAULT_CLASS_TEMPLATES,
431+
new ArrayList<>(), null, new Properties()
432+
);
433+
List<String> paths = new ArrayList<>();
434+
for (String name : simpleNames) {
435+
paths.add(pkg + "/" + name + ".groovy");
436+
}
437+
tool.add(paths);
438+
MockOutputTool output = new MockOutputTool();
439+
tool.renderToOutput(output, MOCK_DIR);
440+
return output.getText(MOCK_DIR + "/" + pkg + "/" + target + ".html");
441+
}
442+
443+
// GROOVY-12277: an annotation's name and description are source text re-embedded verbatim
444+
// into the declaration block, so unlike a doc comment they carry no passthrough licence.
445+
public void testAnnotationTextIsEncodedInDeclarations() throws Exception {
446+
String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles";
447+
Path tmp = Files.createTempDirectory("annotation-");
448+
Path pkgDir = tmp.resolve(pkg);
449+
Files.createDirectories(pkgDir);
450+
Files.writeString(pkgDir.resolve("Meta.groovy"),
451+
"package " + pkg.replace('/', '.') + "\n" +
452+
"@interface Meta { String value() }\n");
453+
Files.writeString(pkgDir.resolve("Annotated.groovy"),
454+
"package " + pkg.replace('/', '.') + "\n" +
455+
"@Meta('<img src=q onerror=alert(1)>')\n" +
456+
"class Annotated {}\n");
457+
458+
String doc = renderSingle(tmp, pkg, "Annotated");
459+
assertNotNull(doc);
460+
assertFalse("annotation text was emitted as markup in:\n" + doc, doc.contains("<img src=q"));
461+
}
462+
388463
// Auto-strip opt-out: {@snippet file="X" keepHeader=true} preserves the
389464
// file content verbatim, and lang is inferred from the file's extension
390465
// when no explicit lang= is given.

0 commit comments

Comments
 (0)