Skip to content

Commit 3b93afd

Browse files
committed
Escape TAB/LF/CR again when serializing to XML
XML.mustEscape() is shared by XML.escape() (serialization) and XMLTokener.unescapeEntity() (parsing), but the two callers need opposite answers for #x9/#xA/#xD. #1070 added those codepoints to the valid-range clause to fix unescape("
"), which also stopped XML.toString() and JSONML.toString() from escaping literal TAB, LF and CR. Literal occurrences of these characters are subject to whitespace normalization by conforming XML parsers, so a value like "Lettuce\nTomato" loses its line breaks when the generated XML is read back by any standard parser. This regressed #1092. Split the two concerns: mustEscape() returns true for every ISO control character again (serialization), and the new isValidXmlCharacter() checks the XML 1.0 character range for numeric character references during parsing. unescapeEntity() now validates with isValidXmlCharacter(), so 	, 
 and 
 keep being accepted (#1059/#1070 behavior unchanged). Fixes #1092
1 parent 69383f7 commit 3b93afd

3 files changed

Lines changed: 80 additions & 20 deletions

File tree

‎src/main/java/org/json/XML.java‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -156,33 +156,39 @@ public static String escape(String string) {
156156

157157
/**
158158
* @param cp code point to test
159-
* @return true if the code point is not valid for an XML
159+
* @return true if the code point must be escaped when serializing to XML
160160
*/
161161
static boolean mustEscape(int cp) {
162+
// Every ISO control character must be written as a numeric character
163+
// reference when serializing. This includes #x9 (tab), #xA (LF) and
164+
// #xD (CR): literal occurrences of these characters are subject to
165+
// whitespace normalization by conforming XML parsers, so they can
166+
// only survive a round trip as 	, 
 and 
 (see #1092).
167+
//
168+
// This method is only about the serialization path. Numeric character
169+
// references found while parsing are validated with
170+
// isValidXmlCharacter(int), which accepts the XML-allowed whitespace
171+
// control characters (see #1059).
172+
return Character.isISOControl(cp) || !isValidXmlCharacter(cp);
173+
}
174+
175+
/**
176+
* @param cp code point to test
177+
* @return true if the code point is a valid XML 1.0 character
178+
*/
179+
static boolean isValidXmlCharacter(int cp) {
162180
/* Valid range from https://www.w3.org/TR/REC-xml/#charsets
163181
*
164182
* #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
165183
*
166184
* any Unicode character, excluding the surrogate blocks, FFFE, and FFFF.
167185
*/
168-
// isISOControl is true when (cp >= 0 && cp <= 0x1F) || (cp >= 0x7F && cp <= 0x9F)
169-
// all ISO control characters are out of range except tabs and new lines
170-
return (Character.isISOControl(cp)
171-
&& cp != 0x9
172-
&& cp != 0xA
173-
&& cp != 0xD
174-
) || !(
175-
// Valid character range per W3C XML 1.0 spec:
176-
// #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
177-
// Previously omitted #x9/#xA/#xD, causing unescape("&#10;") etc.
178-
// to reject valid LF/TAB/CR as illegal (see #1059)
179-
cp == 0x9
180-
|| cp == 0xA
181-
|| cp == 0xD
182-
|| (cp >= 0x20 && cp <= 0xD7FF)
183-
|| (cp >= 0xE000 && cp <= 0xFFFD)
184-
|| (cp >= 0x10000 && cp <= 0x10FFFF)
185-
)
186+
return cp == 0x9
187+
|| cp == 0xA
188+
|| cp == 0xD
189+
|| (cp >= 0x20 && cp <= 0xD7FF)
190+
|| (cp >= 0xE000 && cp <= 0xFFFD)
191+
|| (cp >= 0x10000 && cp <= 0x10FFFF)
186192
;
187193
}
188194

‎src/main/java/org/json/XMLTokener.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ static String unescapeEntity(String e) throws JSONException {
167167
int cp = (e.charAt(1) == 'x' || e.charAt(1) == 'X')
168168
? parseHexEntity(e)
169169
: parseDecimalEntity(e);
170-
if (XML.mustEscape(cp)) {
170+
// Validate against the XML 1.0 character range, not against
171+
// XML.mustEscape: the serialization path escapes TAB/LF/CR, but
172+
// numeric character references for them (&#9;, &#10;, &#13;) are
173+
// valid XML and must be accepted here (see #1059 and #1092).
174+
if (!XML.isValidXmlCharacter(cp)) {
171175
throw new JSONException("Invalid numeric character reference: &#" + e.substring(1) + ";");
172176
}
173177
return new String(new int[] {cp}, 0, 1);

‎src/test/java/org/json/junit/XMLTest.java‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,6 +1610,56 @@ public void testValidWhitespaceNumericEntityToJSONObject() {
16101610
XML.toJSONObject("<a>&#13;</a>");
16111611
}
16121612

1613+
/**
1614+
* Tests that serializing a string value containing literal TAB, LF and CR
1615+
* characters writes numeric character references (&amp;#9;, &amp;#10;,
1616+
* &amp;#13;) instead of the literal characters. Literal occurrences of
1617+
* these characters are subject to whitespace normalization by conforming
1618+
* XML parsers, so the original value would be lost when the generated XML
1619+
* is read back. Regression test for #1092, where #1070 stopped
1620+
* XML.toString() and JSONML.toString() from escaping these characters.
1621+
*/
1622+
@Test
1623+
public void testEscapesLiteralWhitespaceControlCharsOnSerialization() {
1624+
// LF in a text node must survive as a numeric character reference
1625+
JSONObject jo = new JSONObject().put("value", "Lettuce\nTomato");
1626+
String xmlOutput = XML.toString(jo);
1627+
assertTrue("XML should escape literal LF to preserve it, got: " + xmlOutput,
1628+
xmlOutput.contains("&#xa;"));
1629+
1630+
// TAB and CR likewise
1631+
JSONObject tabJo = new JSONObject().put("value", "a\tb\rc");
1632+
String tabXml = XML.toString(tabJo);
1633+
assertTrue("XML should escape literal TAB, got: " + tabXml, tabXml.contains("&#x9;"));
1634+
assertTrue("XML should escape literal CR, got: " + tabXml, tabXml.contains("&#xd;"));
1635+
}
1636+
1637+
/**
1638+
* Tests that the generated XML round-trips: the numeric character
1639+
* references produced for TAB/LF/CR are read back by
1640+
* {@code XML.toJSONObject} as the original characters.
1641+
*/
1642+
@Test
1643+
public void testWhitespaceControlCharsRoundTripThroughXml() {
1644+
String original = "Lettuce\nTomato\tMustard";
1645+
JSONObject jo = new JSONObject().put("value", original);
1646+
String xmlOutput = XML.toString(jo);
1647+
JSONObject reparsed = XML.toJSONObject(xmlOutput);
1648+
assertEquals("LF/TAB must survive the XML round trip",
1649+
original, reparsed.getString("value"));
1650+
}
1651+
1652+
/**
1653+
* Same escaping requirement for {@code JSONML.toString}.
1654+
*/
1655+
@Test
1656+
public void testJSONMLToStringEscapesLiteralNewlines() {
1657+
JSONObject jo = new JSONObject("{\"tagName\":\"MyTag\", \"value\":\"Lettuce\\nTomato\"}");
1658+
String xmlOutput = JSONML.toString(jo);
1659+
assertTrue("JSONML should escape literal LF to preserve it, got: " + xmlOutput,
1660+
xmlOutput.contains("&#xa;"));
1661+
}
1662+
16131663
}
16141664

16151665

0 commit comments

Comments
 (0)