-
Notifications
You must be signed in to change notification settings - Fork 152
fix: disable HTML escaping in protobuf JSON serialization across REST and JSON-RPC transports #947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package org.a2aproject.sdk.util; | ||
|
|
||
| /** | ||
| * Utilities for removing HTML escaping applied by Gson's {@link com.google.gson.stream.JsonWriter} | ||
| * when {@code htmlSafe} is enabled (the default). | ||
| */ | ||
| public final class HtmlEscapeUtils { | ||
|
|
||
| private HtmlEscapeUtils() { | ||
| } | ||
|
|
||
| /** | ||
| * Removes HTML escaping applied by Gson's {@link com.google.gson.stream.JsonWriter} when | ||
| * {@code htmlSafe} is enabled (the default). Restores literal | ||
| * {@code <}, {@code >}, {@code &}, {@code =}, and {@code '}. | ||
| * <p> | ||
| * Gson also escapes U+2028 (line separator) and U+2029 (paragraph separator) in HTML-safe | ||
| * mode, but those are left as-is because they are valid JSON encodings that preserve the | ||
| * original characters without data corruption. | ||
| * | ||
| * @param json the JSON string potentially containing HTML-escaped sequences | ||
| * @return the JSON string with literal characters restored | ||
| */ | ||
| public static String removeHtmlEscaping(String json) { | ||
| return json.replace("\\u003c", "<") | ||
| .replace("\\u003e", ">") | ||
| .replace("\\u0026", "&") | ||
| .replace("\\u003d", "=") | ||
| .replace("\\u0027", "'"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package org.a2aproject.sdk.util; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| public class HtmlEscapeUtilsTest { | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_restoresAngleBrackets() { | ||
| assertEquals("<event-topic>", HtmlEscapeUtils.removeHtmlEscaping("\\u003cevent-topic\\u003e")); | ||
| } | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_restoresAmpersand() { | ||
| assertEquals("foo&bar", HtmlEscapeUtils.removeHtmlEscaping("foo\\u0026bar")); | ||
| } | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_restoresEquals() { | ||
| assertEquals("a=b", HtmlEscapeUtils.removeHtmlEscaping("a\\u003db")); | ||
| } | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_restoresApostrophe() { | ||
| assertEquals("it's", HtmlEscapeUtils.removeHtmlEscaping("it\\u0027s")); | ||
| } | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_handlesMultipleEscapes() { | ||
| assertEquals("<tag>&</tag>", | ||
| HtmlEscapeUtils.removeHtmlEscaping("\\u003ctag\\u003e\\u0026\\u003c/tag\\u003e")); | ||
| } | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_leavesRegularJsonUntouched() { | ||
| String json = "{\"key\": \"value\", \"num\": 42}"; | ||
| assertEquals(json, HtmlEscapeUtils.removeHtmlEscaping(json)); | ||
| } | ||
|
|
||
| @Test | ||
| public void removeHtmlEscaping_handlesEmptyString() { | ||
| assertEquals("", HtmlEscapeUtils.removeHtmlEscaping("")); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -41,6 +41,7 @@ | |||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.spec.Task_v0_3; | ||||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; | ||||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; | ||||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; | ||||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; | ||||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.spec.A2AClientError_v0_3; | ||||||||||||||||||||||||||||||||
| import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; | ||||||||||||||||||||||||||||||||
|
|
@@ -49,6 +50,7 @@ | |||||||||||||||||||||||||||||||
| import java.io.IOException; | ||||||||||||||||||||||||||||||||
| import java.util.Collections; | ||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||
| import java.util.logging.Level; | ||||||||||||||||||||||||||||||||
| import java.util.logging.Logger; | ||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||
| import java.util.concurrent.CompletableFuture; | ||||||||||||||||||||||||||||||||
|
|
@@ -375,18 +377,23 @@ private String sendPostRequest(String url, PayloadAndHeaders_v0_3 payloadAndHead | |||||||||||||||||||||||||||||||
| A2AHttpClient.PostBuilder builder = createPostBuilder(url, payloadAndHeaders); | ||||||||||||||||||||||||||||||||
| A2AHttpResponse response = builder.post(); | ||||||||||||||||||||||||||||||||
| if (!response.success()) { | ||||||||||||||||||||||||||||||||
| log.fine("Error on POST processing " + JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload())); | ||||||||||||||||||||||||||||||||
| if (log.isLoggable(Level.FINE)) { | ||||||||||||||||||||||||||||||||
| log.fine("Error on POST processing " + ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), (MessageOrBuilder) payloadAndHeaders.getPayload())); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| throw RestErrorMapper_v0_3.mapRestError(response); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
379
to
384
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling To fix both issues, wrap the logging statement in an
Suggested change
|
||||||||||||||||||||||||||||||||
| return response.body(); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| private A2AHttpClient.PostBuilder createPostBuilder(String url, PayloadAndHeaders_v0_3 payloadAndHeaders) throws JsonProcessingException_v0_3, InvalidProtocolBufferException { | ||||||||||||||||||||||||||||||||
| log.fine(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload())); | ||||||||||||||||||||||||||||||||
| String body = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), (MessageOrBuilder) payloadAndHeaders.getPayload()); | ||||||||||||||||||||||||||||||||
| if (log.isLoggable(Level.FINE)) { | ||||||||||||||||||||||||||||||||
| log.fine(body); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| A2AHttpClient.PostBuilder postBuilder = httpClient.createPost() | ||||||||||||||||||||||||||||||||
| .url(url) | ||||||||||||||||||||||||||||||||
| .addHeader("Content-Type", "application/json") | ||||||||||||||||||||||||||||||||
| .body(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload())); | ||||||||||||||||||||||||||||||||
| .body(body); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (payloadAndHeaders.getHeaders() != null) { | ||||||||||||||||||||||||||||||||
| for (Map.Entry<String, String> entry : payloadAndHeaders.getHeaders().entrySet()) { | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package org.a2aproject.sdk.compat03.grpc.utils; | ||
|
|
||
| import com.google.protobuf.InvalidProtocolBufferException; | ||
| import com.google.protobuf.MessageOrBuilder; | ||
| import com.google.protobuf.util.JsonFormat; | ||
| import org.a2aproject.sdk.util.HtmlEscapeUtils; | ||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
| /** | ||
| * Protobuf-to-JSON serialization without HTML escaping. | ||
| * <p> | ||
| * {@link JsonFormat#printer()} delegates to Gson's {@link com.google.gson.stream.JsonWriter} | ||
| * which HTML-escapes {@code <}, {@code >}, and {@code &} by default. This utility | ||
| * removes those escape sequences via {@link HtmlEscapeUtils#removeHtmlEscaping(String)}. | ||
| * <p> | ||
| * Intentionally duplicated from {@code org.a2aproject.sdk.grpc.utils.ProtoJsonUtils} | ||
| * to maintain v0.3 module isolation (compat-0.3/spec-grpc cannot depend on spec-grpc). | ||
| */ | ||
| public final class ProtoJsonUtils_v0_3 { | ||
|
|
||
| private ProtoJsonUtils_v0_3() { | ||
| } | ||
|
|
||
| /** | ||
| * Serializes a protobuf message to JSON using the supplied printer, | ||
| * then removes HTML escaping. | ||
| * | ||
| * @param printer the configured {@link JsonFormat.Printer} (callers choose options | ||
| * such as {@code alwaysPrintFieldsWithNoPresence()} or | ||
| * {@code omittingInsignificantWhitespace()}) | ||
| * @param proto the protobuf message to serialize, or {@code null} | ||
| * @return JSON string without HTML-escaped characters, or empty string if proto is null | ||
| */ | ||
| public static String toJson(JsonFormat.Printer printer, @Nullable MessageOrBuilder proto) throws InvalidProtocolBufferException { | ||
| if (proto == null) { | ||
| return ""; | ||
| } | ||
| return HtmlEscapeUtils.removeHtmlEscaping(printer.print(proto)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package org.a2aproject.sdk.compat03.grpc.utils; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertFalse; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| import java.util.Collections; | ||
|
|
||
| import com.google.protobuf.util.JsonFormat; | ||
|
|
||
| import org.a2aproject.sdk.compat03.spec.Message_v0_3; | ||
| import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; | ||
| import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| public class ProtoJsonUtils_v0_3_Test { | ||
|
|
||
| @Test | ||
| public void toJson_doesNotHtmlEscapeAngleBrackets() throws Exception { | ||
| MessageSendParams_v0_3 params = new MessageSendParams_v0_3( | ||
| new Message_v0_3.Builder() | ||
| .role(Message_v0_3.Role.USER) | ||
| .parts(Collections.singletonList(new TextPart_v0_3("<event-topic>"))) | ||
| .contextId("context-1234") | ||
| .messageId("message-1234") | ||
| .build(), | ||
| null, null | ||
| ); | ||
| var proto = ProtoUtils_v0_3.ToProto.sendMessageRequest(params); | ||
|
|
||
| String json = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), proto); | ||
|
|
||
| assertTrue(json.contains("<event-topic>"), | ||
| "JSON should preserve literal '<event-topic>' but got: " + json); | ||
| assertFalse(json.contains("\\u003c"), | ||
| "JSON must not contain HTML-escaped '<' (\\u003c) but got: " + json); | ||
| assertFalse(json.contains("\\u003e"), | ||
| "JSON must not contain HTML-escaped '>' (\\u003e) but got: " + json); | ||
| } | ||
|
|
||
| @Test | ||
| public void toJson_doesNotHtmlEscapeAmpersand() throws Exception { | ||
| MessageSendParams_v0_3 params = new MessageSendParams_v0_3( | ||
| new Message_v0_3.Builder() | ||
| .role(Message_v0_3.Role.USER) | ||
| .parts(Collections.singletonList(new TextPart_v0_3("foo&bar"))) | ||
| .contextId("context-1234") | ||
| .messageId("message-1234") | ||
| .build(), | ||
| null, null | ||
| ); | ||
| var proto = ProtoUtils_v0_3.ToProto.sendMessageRequest(params); | ||
|
|
||
| String json = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), proto); | ||
|
|
||
| assertTrue(json.contains("foo&bar"), | ||
| "JSON should preserve literal '&' but got: " + json); | ||
| assertFalse(json.contains("\\u0026"), | ||
| "JSON must not contain HTML-escaped '&' (\\u0026) but got: " + json); | ||
| } | ||
|
|
||
| @Test | ||
| public void toJson_respectsAlwaysPrintFieldsWithNoPresence() throws Exception { | ||
| MessageSendParams_v0_3 params = new MessageSendParams_v0_3( | ||
| new Message_v0_3.Builder() | ||
| .role(Message_v0_3.Role.USER) | ||
| .parts(Collections.singletonList(new TextPart_v0_3("hello"))) | ||
| .contextId("context-1234") | ||
| .messageId("message-1234") | ||
| .build(), | ||
| null, null | ||
| ); | ||
| var proto = ProtoUtils_v0_3.ToProto.sendMessageRequest(params); | ||
|
|
||
| String withDefaults = ProtoJsonUtils_v0_3.toJson( | ||
| JsonFormat.printer().alwaysPrintFieldsWithNoPresence(), proto); | ||
| String withoutDefaults = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), proto); | ||
|
|
||
| assertTrue(withDefaults.length() > withoutDefaults.length(), | ||
| "alwaysPrintFieldsWithNoPresence should produce more fields, got:\n with: " + withDefaults + "\n without: " + withoutDefaults); | ||
| } | ||
|
|
||
| @Test | ||
| public void toJson_returnsEmptyStringForNull() throws Exception { | ||
| String result = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), null); | ||
|
|
||
| assertEquals("", result); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling
printProtoAsJsoninsidelog.finecauses the expensive double-serialization and parsing of the protobuf payload to run eagerly on every failed request, even whenFINElogging is disabled. Additionally, ifprintProtoAsJsonthrows anInvalidProtocolBufferExceptionduring serialization, it will propagate that exception instead of the actual mapped REST error (RestErrorMapper.mapRestError(response)).To fix both issues, wrap the logging statement in an
if (log.isLoggable(java.util.logging.Level.FINE))block and handle any potential serialization exceptions within it.