This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new c3f5dd643f23 CAMEL-25301: camel-cloudevents - 
application-cloudevents+json escapes the attribute values and the text data 
(#27342)
c3f5dd643f23 is described below

commit c3f5dd643f239df81b8a51e31fcf45cd38c541e0
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 5 22:34:22 2026 +0530

    CAMEL-25301: camel-cloudevents - application-cloudevents+json escapes the 
attribute values and the text data (#27342)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../CloudEventJsonDataTypeTransformer.java         | 235 +++++++++++++++++++--
 .../CloudEventJsonDataTypeTransformerTest.java     | 111 ++++++++++
 2 files changed, 332 insertions(+), 14 deletions(-)

diff --git 
a/components/camel-cloudevents/src/main/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformer.java
 
b/components/camel-cloudevents/src/main/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformer.java
index 495cdf696d1a..cc7be5efbc93 100644
--- 
a/components/camel-cloudevents/src/main/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformer.java
+++ 
b/components/camel-cloudevents/src/main/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformer.java
@@ -17,6 +17,7 @@
 
 package org.apache.camel.component.cloudevents.transformer;
 
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -41,6 +42,14 @@ public class CloudEventJsonDataTypeTransformer extends 
Transformer {
     public static final String APPLICATION_CLOUDEVENTS_JSON = 
"application/cloudevents+json";
     public static final String APPLICATION_JSON = "application/json";
 
+    // the states of the Json scanner in isJson
+    private static final int VALUE = 0;
+    private static final int VALUE_OR_END = 1;
+    private static final int KEY = 2;
+    private static final int KEY_OR_END = 3;
+    private static final int COLON = 4;
+    private static final int COMMA_OR_END = 5;
+
     @Override
     public void transform(Message message, DataType fromType, DataType toType) 
{
         final Map<String, Object> headers = message.getHeaders();
@@ -84,19 +93,16 @@ public class CloudEventJsonDataTypeTransformer extends 
Transformer {
         StringBuilder builder = new StringBuilder("{");
 
         cloudEventAttributes.forEach((key, value) -> {
-            if ("data".equals(key) && value instanceof String data) {
-                if (isJson(data)) {
-                    // set Json data as nested object in the data field
-                    builder.append(" 
").append("\"").append(key).append("\"").append(":").append(data)
-                            .append(",");
-                } else {
-                    builder.append(" 
").append("\"").append(key).append("\"").append(":").append("\"").append(data).append("\"")
-                            .append(",");
-                }
+            builder.append(" ");
+            appendJsonString(builder, key);
+            builder.append(":");
+            if ("data".equals(key) && value instanceof String data && 
isJson(data)) {
+                // set Json data as nested object in the data field
+                builder.append(data);
             } else {
-                builder.append(" 
").append("\"").append(key).append("\"").append(":").append("\"").append(value).append("\"")
-                        .append(",");
+                appendJsonString(builder, String.valueOf(value));
             }
+            builder.append(",");
         });
 
         if (!cloudEventAttributes.isEmpty()) {
@@ -106,11 +112,212 @@ public class CloudEventJsonDataTypeTransformer extends 
Transformer {
         return builder.append("}").toString();
     }
 
-    private boolean isJson(String data) {
-        if (data == null || data.isEmpty()) {
+    /**
+     * Appends the value as a Json string, escaping the quote, the backslash 
and the control characters (RFC 8259,
+     * section 7).
+     */
+    private static void appendJsonString(StringBuilder builder, String value) {
+        builder.append('"');
+        for (int i = 0; i < value.length(); i++) {
+            char ch = value.charAt(i);
+            switch (ch) {
+                case '"' -> builder.append("\\\"");
+                case '\\' -> builder.append("\\\\");
+                case '\n' -> builder.append("\\n");
+                case '\r' -> builder.append("\\r");
+                case '\t' -> builder.append("\\t");
+                case '\b' -> builder.append("\\b");
+                case '\f' -> builder.append("\\f");
+                default -> {
+                    if (ch < 0x20) {
+                        builder.append(String.format("\\u%04x", (int) ch));
+                    } else {
+                        builder.append(ch);
+                    }
+                }
+            }
+        }
+        builder.append('"');
+    }
+
+    /**
+     * Whether the data is a Json object or array, which is then set as nested 
Json value. Text that only starts like
+     * Json (such as a log line "[INFO] ...") is set as a Json string.
+     * <p/>
+     * The data is checked with a single pass over its characters that 
validates the Json grammar (RFC 8259) without
+     * building the objects, so a large Json body costs little more than the 
copy into the event, and a body that is not
+     * valid Json can never make the event invalid Json.
+     */
+    static boolean isJson(String data) {
+        if (data == null) {
             return false;
         }
+        final int length = data.length();
+        int i = skipWhitespace(data, 0, length);
+        if (i == length || data.charAt(i) != '{' && data.charAt(i) != '[') {
+            return false;
+        }
+
+        // the open objects (true) and arrays (false), which grows only for 
deeply nested data
+        boolean[] objects = new boolean[32];
+        int depth = 0;
+        int state = VALUE;
+        while (true) {
+            i = skipWhitespace(data, i, length);
+            if (i == length) {
+                // unterminated object or array
+                return false;
+            }
+            char ch = data.charAt(i);
+            if ((state == VALUE_OR_END && ch == ']') || (state == KEY_OR_END 
&& ch == '}')
+                    || (state == COMMA_OR_END && ch == (objects[depth - 1] ? 
'}' : ']'))) {
+                // closes the current object or array
+                i++;
+                depth--;
+                if (depth == 0) {
+                    // nothing but whitespace may follow the closed value
+                    return skipWhitespace(data, i, length) == length;
+                }
+                state = COMMA_OR_END;
+            } else if (state == VALUE || state == VALUE_OR_END) {
+                if (ch == '{' || ch == '[') {
+                    if (depth == objects.length) {
+                        objects = Arrays.copyOf(objects, depth * 2);
+                    }
+                    objects[depth++] = ch == '{';
+                    i++;
+                    state = ch == '{' ? KEY_OR_END : VALUE_OR_END;
+                } else {
+                    i = skipScalar(data, i, length);
+                    state = COMMA_OR_END;
+                }
+            } else if (state == KEY || state == KEY_OR_END) {
+                i = ch == '"' ? skipString(data, i, length) : -1;
+                state = COLON;
+            } else if (state == COLON) {
+                i = ch == ':' ? i + 1 : -1;
+                state = VALUE;
+            } else if (ch == ',') {
+                // state is COMMA_OR_END
+                i++;
+                state = objects[depth - 1] ? KEY : VALUE;
+            } else {
+                return false;
+            }
+            if (i < 0) {
+                return false;
+            }
+        }
+    }
+
+    private static int skipWhitespace(String data, int index, int length) {
+        while (index < length) {
+            char ch = data.charAt(index);
+            if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') {
+                break;
+            }
+            index++;
+        }
+        return index;
+    }
+
+    /**
+     * Skips a Json string, number, true, false or null at the index, and 
returns the index after it, or -1 if it is not
+     * valid.
+     */
+    private static int skipScalar(String data, int index, int length) {
+        char ch = data.charAt(index);
+        if (ch == '"') {
+            return skipString(data, index, length);
+        } else if (ch == '-' || (ch >= '0' && ch <= '9')) {
+            return skipNumber(data, index, length);
+        } else if (data.startsWith("true", index)) {
+            return index + 4;
+        } else if (data.startsWith("false", index)) {
+            return index + 5;
+        } else if (data.startsWith("null", index)) {
+            return index + 4;
+        }
+        return -1;
+    }
+
+    /**
+     * Skips the Json string that starts with the quote at the index, and 
returns the index after its closing quote, or
+     * -1 if it is not valid (unterminated, an unknown escape, or a raw 
control character).
+     */
+    private static int skipString(String data, int index, int length) {
+        int i = index + 1;
+        while (i < length) {
+            char ch = data.charAt(i++);
+            if (ch == '"') {
+                return i;
+            } else if (ch == '\\') {
+                if (i == length) {
+                    return -1;
+                }
+                char escaped = data.charAt(i++);
+                if (escaped == 'u') {
+                    if (i + 4 > length) {
+                        return -1;
+                    }
+                    for (int end = i + 4; i < end; i++) {
+                        if (Character.digit(data.charAt(i), 16) < 0) {
+                            return -1;
+                        }
+                    }
+                } else if ("\"\\/bfnrt".indexOf(escaped) < 0) {
+                    return -1;
+                }
+            } else if (ch < 0x20) {
+                return -1;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Skips the Json number at the index: an optional minus, an integer part 
without leading zeros, an optional
+     * fraction and an optional exponent. Returns the index after it, or -1 if 
it is not valid.
+     */
+    private static int skipNumber(String data, int index, int length) {
+        int i = index;
+        if (data.charAt(i) == '-') {
+            i++;
+        }
+        if (i < length && data.charAt(i) == '0') {
+            i++;
+        } else {
+            int start = i;
+            i = skipDigits(data, i, length);
+            if (i == start) {
+                return -1;
+            }
+        }
+        if (i < length && data.charAt(i) == '.') {
+            int start = ++i;
+            i = skipDigits(data, i, length);
+            if (i == start) {
+                return -1;
+            }
+        }
+        if (i < length && (data.charAt(i) == 'e' || data.charAt(i) == 'E')) {
+            i++;
+            if (i < length && (data.charAt(i) == '+' || data.charAt(i) == 
'-')) {
+                i++;
+            }
+            int start = i;
+            i = skipDigits(data, i, length);
+            if (i == start) {
+                return -1;
+            }
+        }
+        return i;
+    }
 
-        return data.trim().startsWith("{") || data.trim().startsWith("[");
+    private static int skipDigits(String data, int index, int length) {
+        while (index < length && data.charAt(index) >= '0' && 
data.charAt(index) <= '9') {
+            index++;
+        }
+        return index;
     }
 }
diff --git 
a/components/camel-cloudevents/src/test/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformerTest.java
 
b/components/camel-cloudevents/src/test/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformerTest.java
index 54eb19476d7e..f9ba5294e240 100644
--- 
a/components/camel-cloudevents/src/test/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformerTest.java
+++ 
b/components/camel-cloudevents/src/test/java/org/apache/camel/component/cloudevents/transformer/CloudEventJsonDataTypeTransformerTest.java
@@ -28,10 +28,13 @@ import org.apache.camel.spi.DataType;
 import org.apache.camel.spi.Transformer;
 import org.apache.camel.spi.TransformerKey;
 import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -130,6 +133,114 @@ class CloudEventJsonDataTypeTransformerTest {
         
assertTrue(exchange.getMessage().getBody(String.class).contains("\"data\":\"Test\""));
     }
 
+    @Test
+    void shouldEscapeTextData() throws Exception {
+        Exchange exchange = new DefaultExchange(camelContext);
+
+        String text = "He said \"hi\"\nC:\\temp\\new\tdone\u0001";
+        
exchange.getMessage().setHeader(CloudEvent.CAMEL_CLOUD_EVENT_CONTENT_TYPE, 
"text/plain");
+        exchange.getMessage().setBody(text);
+
+        transformer.transform(exchange.getMessage(), DataType.ANY, 
DataType.ANY);
+
+        String json = exchange.getMessage().getBody(String.class);
+        // JSON strings must not contain raw control characters (Jsoner would 
accept them)
+        assertTrue(json.chars().noneMatch(ch -> ch < 0x20), json);
+        assertTrue(json.contains("\\n") && json.contains("\\t") && 
json.contains("\\u0001"), json);
+        JsonObject event = (JsonObject) Jsoner.deserialize(json);
+        assertEquals(text, event.getString("data"));
+        assertEquals("text/plain", event.getString(
+                
CloudEvents.v1_0.mandatoryAttribute(CloudEvent.CAMEL_CLOUD_EVENT_DATA_CONTENT_TYPE).json()));
+    }
+
+    @Test
+    void shouldKeepTextDataThatOnlyLooksLikeJson() throws Exception {
+        Exchange exchange = new DefaultExchange(camelContext);
+
+        String text = "[INFO] order 42 received";
+        
exchange.getMessage().setHeader(CloudEvent.CAMEL_CLOUD_EVENT_CONTENT_TYPE, 
"text/plain");
+        exchange.getMessage().setBody(text);
+
+        transformer.transform(exchange.getMessage(), DataType.ANY, 
DataType.ANY);
+
+        JsonObject event = (JsonObject) 
Jsoner.deserialize(exchange.getMessage().getBody(String.class));
+        assertEquals(text, event.getString("data"));
+    }
+
+    @Test
+    void shouldEscapeAttributeValues() throws Exception {
+        Exchange exchange = new DefaultExchange(camelContext);
+
+        String subject = "reports\\2026 \"Q3\".csv";
+        exchange.getMessage().setHeader(CloudEvent.CAMEL_CLOUD_EVENT_SUBJECT, 
subject);
+        
exchange.getMessage().setHeader(CloudEvent.CAMEL_CLOUD_EVENT_CONTENT_TYPE, 
"text/plain");
+        exchange.getMessage().setBody("Test");
+
+        transformer.transform(exchange.getMessage(), DataType.ANY, 
DataType.ANY);
+
+        JsonObject event = (JsonObject) 
Jsoner.deserialize(exchange.getMessage().getBody(String.class));
+        assertEquals(subject,
+                
event.getString(CloudEvents.v1_0.mandatoryAttribute(CloudEvent.CAMEL_CLOUD_EVENT_SUBJECT).json()));
+        assertEquals("Test", event.getString("data"));
+    }
+
+    @Test
+    void shouldNestJsonData() throws Exception {
+        Exchange exchange = new DefaultExchange(camelContext);
+
+        
exchange.getMessage().setHeader(CloudEvent.CAMEL_CLOUD_EVENT_CONTENT_TYPE, 
"application/json");
+        exchange.getMessage().setBody("{\"message\": \"He said \\\"hi\\\"\", 
\"items\": [1, 2]}");
+
+        transformer.transform(exchange.getMessage(), DataType.ANY, 
DataType.ANY);
+
+        JsonObject event = (JsonObject) 
Jsoner.deserialize(exchange.getMessage().getBody(String.class));
+        JsonObject data = (JsonObject) event.get("data");
+        assertEquals("He said \"hi\"", data.getString("message"));
+        assertEquals(2, data.getCollection("items").size());
+    }
+
+    @Test
+    void shouldKeepMalformedJsonDataAsText() throws Exception {
+        Exchange exchange = new DefaultExchange(camelContext);
+
+        // declared as Json, but cut short: nesting it would make the event 
invalid Json
+        String text = "{\"message\": \"Test1\", \"items\": [1, 2";
+        
exchange.getMessage().setHeader(CloudEvent.CAMEL_CLOUD_EVENT_CONTENT_TYPE, 
"application/json");
+        exchange.getMessage().setBody(text);
+
+        transformer.transform(exchange.getMessage(), DataType.ANY, 
DataType.ANY);
+
+        JsonObject event = (JsonObject) 
Jsoner.deserialize(exchange.getMessage().getBody(String.class));
+        assertEquals(text, event.getString("data"));
+    }
+
+    @Test
+    void shouldRecognizeJsonObjectsAndArrays() {
+        assertTrue(CloudEventJsonDataTypeTransformer.isJson("{}"));
+        assertTrue(CloudEventJsonDataTypeTransformer.isJson("[]"));
+        assertTrue(CloudEventJsonDataTypeTransformer.isJson(" \r\n\t{ } \n"));
+        
assertTrue(CloudEventJsonDataTypeTransformer.isJson("[{\"a\":{\"b\":[[], {}]}}, 
\"\"]"));
+        assertTrue(CloudEventJsonDataTypeTransformer.isJson(
+                "{\"a\": [0, -1, 12.5, -0.25e10, 1E+2, 3e-4, true, false, 
null], \"s\": \"\\\"\\\\\\/\\b\\f\\n\\r\\t\\u00e9\u00e9\"}"));
+        // deeper than the initial nesting stack
+        assertTrue(CloudEventJsonDataTypeTransformer.isJson("[".repeat(100) + 
"{\"a\":1}" + "]".repeat(100)));
+    }
+
+    @Test
+    void shouldRejectTextThatIsNotJsonObjectOrArray() {
+        for (String text : new String[] {
+                null, "", "  ", "Test", "\"text\"", "42", "true", "null",
+                "[INFO] order 42 received", "{ \"a\": 1 } trailing", "[1] [2]",
+                "{", "[", "{\"a\": 1", "[1, 2", "{\"a\": 1]", "[1}",
+                "[1,]", "{\"a\": 1,}", "[,1]", "{,}", "[1 2]", "{\"a\" 1}", 
"{\"a\": 1 \"b\": 2}", "{a: 1}", "{'a': 1}",
+                "{\"a\"}", "{\"a\":}", "[01]", "[1.]", "[.5]", "[-]", "[1e]", 
"[1e+]", "[+1]", "[0x1F]",
+                "[tru]", "[nul]", "[True]", "[NaN]",
+                "[\"open]", "[\"a\\x\"]", "[\"a\\u12g4\"]", "[\"a\\u12\"]", 
"[\"a\\\"]",
+                "[\"raw\u0001control\"]", "[\"raw\nnew line\"]", "\u000b{}" }) 
{
+            assertFalse(CloudEventJsonDataTypeTransformer.isJson(text), text);
+        }
+    }
+
     @Test
     public void shouldLookupTransformer() throws Exception {
         Transformer transformer

Reply via email to