Skip to content

Commit ff20e0a

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 ff20e0a

2 files changed

Lines changed: 112 additions & 7 deletions

File tree

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

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

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-
}
243282

244283
/**
245284
* 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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,72 @@ 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 a
278+
// less-than in a value produced markup that is not well formed, and a greater-than was left
279+
// unescaped where element text escapes it.
280+
@Test
281+
void testAttributeValuesAreEscapedLikeElementText() {
282+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
283+
def template = engine.createTemplate '''
284+
html {
285+
div(title: value)
286+
}
287+
'''
288+
StringWriter rendered = new StringWriter()
289+
template.make([value: 'a & b < c > d']).writeTo(rendered)
290+
assert rendered.toString() == "<html><div title='a &amp; b &lt; c &gt; d'/></html>"
291+
}
292+
293+
// The delimiter still cannot end the value, and the other quote is neither unsafe nor
294+
// ill-formed inside one, so it is left as written.
295+
@Test
296+
void testAttributeValueCannotEndItsOwnAttribute() {
297+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
298+
def template = engine.createTemplate '''
299+
html {
300+
div(title: value)
301+
}
302+
'''
303+
StringWriter rendered = new StringWriter()
304+
template.make([value: "x' onmouseover='alert(1)"]).writeTo(rendered)
305+
assert !rendered.toString().contains("onmouseover='alert")
306+
assert rendered.toString().contains('&apos;')
307+
}
308+
309+
// GROOVY-12278: a name has no escaped form, so a name taken from data which is not a name is
310+
// refused rather than written out to introduce attributes of its own.
311+
@Test
312+
void testAttributeNameTakenFromDataMustBeAName() {
313+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
314+
def template = engine.createTemplate '''
315+
html {
316+
div(attrs)
317+
}
318+
'''
319+
StringWriter rendered = new StringWriter()
320+
def err = shouldFail(IllegalArgumentException) {
321+
template.make([attrs: ["x='1' onmouseover='alert(1)'": 'y']]).writeTo(rendered)
322+
}
323+
assert err.message.contains('Invalid markup attribute name')
324+
}
325+
326+
@Test
327+
void testOrdinaryAttributeNamesStillRender() {
328+
def engine = new MarkupTemplateEngine(new TemplateConfiguration())
329+
def template = engine.createTemplate '''
330+
html {
331+
div('data-id': 1, 'xlink:href': 'a', _private: 2, 'a.b': 3)
332+
}
333+
'''
334+
StringWriter rendered = new StringWriter()
335+
template.make().writeTo(rendered)
336+
assert rendered.toString().contains("data-id='1'")
337+
assert rendered.toString().contains("xlink:href='a'")
338+
assert rendered.toString().contains("_private='2'")
339+
assert rendered.toString().contains("a.b='3'")
340+
}
341+
276342
@Test
277343
void testTagsWithAttributesAndDoubleQuotes() {
278344
def engine = new MarkupTemplateEngine(new TemplateConfiguration())

0 commit comments

Comments
 (0)