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");

Reply via email to