This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/json-special-chars in repository https://gitbox.apache.org/repos/asf/cxf.git
commit 43b49e8baa1bcb063c61378a4bacbddf3d8504e1 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Tue May 26 09:49:07 2026 +0100 Escape JSON control characters --- .../json/basic/JsonMapObjectReaderWriter.java | 12 +++++- .../json/basic/JsonMapObjectReaderWriterTest.java | 44 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/rt/rs/extensions/json-basic/src/main/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriter.java b/rt/rs/extensions/json-basic/src/main/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriter.java index 474e0832745..3522a6a496c 100644 --- a/rt/rs/extensions/json-basic/src/main/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriter.java +++ b/rt/rs/extensions/json-basic/src/main/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriter.java @@ -487,8 +487,18 @@ public class JsonMapObjectReaderWriter { StringBuilder sb = new StringBuilder(); for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); + if (c < 0x20) { + // RFC 8259 section 7: all control characters (U+0000–U+001F) MUST be escaped. + switch (c) { + case '\b': sb.append("\\b"); break; + case '\t': sb.append("\\t"); break; + case '\n': sb.append("\\n"); break; + case '\f': sb.append("\\f"); break; + case '\r': sb.append("\\r"); break; + default: sb.append(String.format("\\u%04x", (int) c)); break; + } // If we have " and the previous char was not \ then escape it - if (c == '"' && (i == 0 || value.charAt(i - 1) != '\\')) { + } else if (c == '"' && (i == 0 || value.charAt(i - 1) != '\\')) { sb.append('\\').append(c); // If we have \ and the previous char was not \ and the next char is not an escaped char, then escape it } else if (c == '\\' && (i == 0 || value.charAt(i - 1) != '\\') diff --git a/rt/rs/extensions/json-basic/src/test/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriterTest.java b/rt/rs/extensions/json-basic/src/test/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriterTest.java index c133de675db..77b20e410a0 100644 --- a/rt/rs/extensions/json-basic/src/test/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriterTest.java +++ b/rt/rs/extensions/json-basic/src/test/java/org/apache/cxf/jaxrs/json/basic/JsonMapObjectReaderWriterTest.java @@ -31,6 +31,7 @@ import org.apache.cxf.helpers.CastUtils; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -327,6 +328,49 @@ public class JsonMapObjectReaderWriterTest { assertEquals("hello", map.get("a")); } + /** + * RFC 8259 section 7 requires that all control characters (U+0000–U+001F) in string + * values be escaped in JSON output. {@code escapeJson} only escapes {@code "} and + * {@code \}; every other control character is emitted verbatim, producing JSON that + * violates the specification and may be rejected or mishandled by strict parsers. + * + * <p>The three tests below cover the most security-relevant cases: + * <ol> + * <li>A raw line-feed (U+000A) must be escaped as {@code \n}.</li> + * <li>A raw horizontal-tab (U+0009) must be escaped as {@code \t}.</li> + * <li>A raw CR+LF sequence must have both bytes escaped — an unescaped CR+LF in a + * JSON value that is subsequently placed in an HTTP response header enables + * HTTP response splitting (header injection).</li> + * </ol> + */ + @Test + public void testRawNewlineInValueIsEscapedInOutput() throws Exception { + // Bug: escapeJson passes U+000A through verbatim; correct output is \n (two chars). + Map<String, Object> map = Collections.singletonMap("msg", "line1\nline2"); + String json = new JsonMapObjectReaderWriter().toJson(map); + assertFalse("Raw newline must not appear verbatim in JSON output", json.contains("\n")); + assertEquals("{\"msg\":\"line1\\nline2\"}", json); + } + + @Test + public void testRawTabInValueIsEscapedInOutput() throws Exception { + // Bug: escapeJson passes U+0009 through verbatim; correct output is \t (two chars). + Map<String, Object> map = Collections.singletonMap("msg", "col1\tcol2"); + String json = new JsonMapObjectReaderWriter().toJson(map); + assertFalse("Raw tab must not appear verbatim in JSON output", json.contains("\t")); + assertEquals("{\"msg\":\"col1\\tcol2\"}", json); + } + + @Test + public void testCrLfInValueDoesNotEnableHttpResponseSplitting() throws Exception { + // Bug: neither \r nor \n is escaped, so a crafted value can inject arbitrary + // HTTP headers when the JSON output is placed in a response header field. + Map<String, Object> map = Collections.singletonMap("v", "ok\r\nX-Injected: evil"); + String json = new JsonMapObjectReaderWriter().toJson(map); + assertFalse("Raw CR must not appear verbatim in JSON output", json.contains("\r")); + assertFalse("Raw LF must not appear verbatim in JSON output", json.contains("\n")); + } + @Test public void testRejectInfinityNumericValue() { assertInvalidNumericLiteral("Infinity");
