Skip to content

Commit 760fa55

Browse files
committed
GROOVY-12278: Escape markup attribute values and refuse names which are not names
MarkupTemplateEngine writes element text through escapeXml, so a template author can reasonably read the engine as treating the values it is given as data. The attribute path did not hold up that reading. A value was escaped only for the quote character configured as the delimiter, and an attribute name was written exactly as it arrived. Escape a value for the delimiter, as before, and additionally for the ampersand and the angle brackets, which are not well formed inside an attribute value whichever quote surrounds it. The other quote character is neither unsafe nor ill formed there, so it is left as written and output is unchanged for every value that was already well formed. Refuse an attribute name which is not a name. A name has no escaped form: escaping one produces a different name rather than a safe version of the same one, so a name arriving from data is checked and rejected instead. This is the half with teeth, since a map key such as x='1' onmouseover='alert(1)' was previously written out and introduced attributes of its own. Doing so surfaced that xmlDeclaration passed " encoding" as an attribute name, using a leading space as a separator; the space is now written separately and the name is a name. escapeQuotes had no remaining caller and is removed. Behaviour change worth a release note: a template which places an ampersand or an angle bracket in an attribute value now emits it escaped, and one which builds attribute names from data will fail rather than emit markup whose shape the data chose.
1 parent 55dcfb9 commit 760fa55

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

subprojects/groovy-templates/src/main/groovy/groovy/text/markup/BaseTemplate.java

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ public BaseTemplate xmlDeclaration() throws IOException {
168168
out.write("<?xml ");
169169
writeAttribute("version", "1.0");
170170
if (configuration.getDeclarationEncoding() != null) {
171-
writeAttribute(" encoding", configuration.getDeclarationEncoding());
171+
out.write(' ');
172+
writeAttribute("encoding", configuration.getDeclarationEncoding());
172173
}
173174
out.write("?>");
174175
out.write(configuration.getNewLineString());
@@ -213,13 +214,54 @@ public BaseTemplate pi(Map<?, ?> attrs) throws IOException {
213214
}
214215

215216
private void writeAttribute(String attName, String value) throws IOException {
217+
checkAttributeName(attName);
216218
out.write(attName);
217219
out.write("=");
218220
writeQt();
219-
out.write(escapeQuotes(value));
221+
out.write(escapeAttributeValue(value));
220222
writeQt();
221223
}
222224

225+
/**
226+
* Escapes an attribute value: the delimiter in use, which would otherwise end the value,
227+
* and the characters which are not well formed inside one whichever delimiter surrounds it.
228+
* The other quote character is left alone, being neither.
229+
*
230+
* @param str the attribute value
231+
* @return the escaped value
232+
*/
233+
private String escapeAttributeValue(final String str) {
234+
String quote = configuration.isUseDoubleQuotes() ? "\"" : "'";
235+
String escape = configuration.isUseDoubleQuotes() ? "&quot;" : "&apos;";
236+
// The ampersand goes first so the entities introduced after it are not re-escaped.
237+
return str.replace("&", "&amp;")
238+
.replace("<", "&lt;")
239+
.replace(">", "&gt;")
240+
.replace(quote, escape);
241+
}
242+
243+
/**
244+
* Rejects an attribute name which is not a name.
245+
* <p>
246+
* A name has no escaped form: escaping one yields a different name rather than a safe
247+
* version of the same one, and writing it unaltered lets a name taken from data introduce
248+
* further attributes of its own. So an unusable name is refused instead.
249+
*
250+
* @param attName the attribute name to check
251+
* @throws IllegalArgumentException if the name cannot be written as an attribute name
252+
*/
253+
private static void checkAttributeName(final String attName) {
254+
boolean usable = attName != null && !attName.isEmpty()
255+
&& (Character.isLetter(attName.charAt(0)) || attName.charAt(0) == '_' || attName.charAt(0) == ':');
256+
for (int i = 1; usable && i < attName.length(); i += 1) {
257+
char c = attName.charAt(i);
258+
usable = Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '.' || c == ':';
259+
}
260+
if (!usable) {
261+
throw new IllegalArgumentException("Invalid markup attribute name: " + attName);
262+
}
263+
}
264+
223265
private void writeQt() throws IOException {
224266
if (configuration.isUseDoubleQuotes()) {
225267
out.write('"');
@@ -235,11 +277,6 @@ private void writeIndent() throws IOException {
235277
}
236278
}
237279

238-
private String escapeQuotes(String str) {
239-
String quote = configuration.isUseDoubleQuotes() ? "\"" : "'";
240-
String escape = configuration.isUseDoubleQuotes() ? "&quot;" : "&apos;";
241-
return str.replace(quote, escape);
242-
}
243280

244281
/**
245282
* This is the main method responsible for writing a tag and its attributes.

subprojects/groovy-templates/src/test/groovy/groovy/text/MarkupTemplateEngineTest.groovy

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,71 @@ final class MarkupTemplateEngineTest {
273273
assert rendered.toString() == '<html><a href=\'foo.html\'>Link text</a><tagWithQuote attr=\'fo&apos;o\'/></html>'
274274
}
275275

276+
// GROOVY-12278: an attribute value is data, as element text is. yield() escapes all five XML
277+
// metacharacters; the attribute path escaped only the delimiter in use, so an ampersand or
278+
// an angle bracket in a value produced markup that is not well formed.
279+
@Test
280+
void testAttributeValuesAreEscapedLikeElementText() {
281+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
282+
def template = engine.createTemplate '''
283+
html {
284+
div(title: value)
285+
}
286+
'''
287+
StringWriter rendered = new StringWriter()
288+
template.make([value: 'a & b < c > d']).writeTo(rendered)
289+
assert rendered.toString() == "<html><div title='a &amp; b &lt; c &gt; d'/></html>"
290+
}
291+
292+
// The delimiter still cannot end the value, and the other quote is neither unsafe nor
293+
// ill-formed inside one, so it is left as written.
294+
@Test
295+
void testAttributeValueCannotEndItsOwnAttribute() {
296+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
297+
def template = engine.createTemplate '''
298+
html {
299+
div(title: value)
300+
}
301+
'''
302+
StringWriter rendered = new StringWriter()
303+
template.make([value: "x' onmouseover='alert(1)"]).writeTo(rendered)
304+
assert !rendered.toString().contains("onmouseover='alert")
305+
assert rendered.toString().contains('&apos;')
306+
}
307+
308+
// GROOVY-12278: a name has no escaped form, so a name taken from data which is not a name is
309+
// refused rather than written out to introduce attributes of its own.
310+
@Test
311+
void testAttributeNameTakenFromDataMustBeAName() {
312+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
313+
def template = engine.createTemplate '''
314+
html {
315+
div(attrs)
316+
}
317+
'''
318+
StringWriter rendered = new StringWriter()
319+
def err = shouldFail(IllegalArgumentException) {
320+
template.make([attrs: ["x='1' onmouseover='alert(1)'": 'y']]).writeTo(rendered)
321+
}
322+
assert err.message.contains('Invalid markup attribute name')
323+
}
324+
325+
@Test
326+
void testOrdinaryAttributeNamesStillRender() {
327+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
328+
def template = engine.createTemplate '''
329+
html {
330+
div('data-id': 1, 'xlink:href': 'a', _private: 2, 'a.b': 3)
331+
}
332+
'''
333+
StringWriter rendered = new StringWriter()
334+
template.make().writeTo(rendered)
335+
assert rendered.toString().contains("data-id='1'")
336+
assert rendered.toString().contains("xlink:href='a'")
337+
assert rendered.toString().contains("_private='2'")
338+
assert rendered.toString().contains("a.b='3'")
339+
}
340+
276341
@Test
277342
void testTagsWithAttributesAndDoubleQuotes() {
278343
def engine = new MarkupTemplateEngine(new TemplateConfiguration())

0 commit comments

Comments
 (0)