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