You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cxf.apache.org by co...@apache.org on 2026/05/26 08:49:36 UTC

(cxf) 01/01: Escape JSON control characters

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 <co...@apache.org>
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");