Environment
Jmix version: 3.0.1
- Modules involved:
jmix-restds (client side), jmix-rest (service side)
- Spring Boot 4.0.7 / Spring Framework 7.0.8
- gson 2.13.2
- Java 21
- Two applications: a UI application reading a service application through a REST DataStore
(jmix.core.store-descriptor-backend=restds_RestDataStoreDescriptor)
Bug Description
io.jmix.restds.impl.service.RemoteServiceInvoker#getParamsJson builds the request body for a
@RemoteService call by concatenating a StringBuilder, and does not JSON-escape the argument values it
embeds. A String argument is therefore reinterpreted as JSON source on the receiving side, with two
distinct consequences:
- A double quote terminates the JSON string early, the body is malformed, and the call fails with
HTTP 500 and an empty error message.
- A backslash is read as the start of a JSON escape sequence. When it happens to form a valid one the
value is silently altered and the call succeeds — C:\temp arrives as C:<TAB>emp. When it does
not, the call fails with the same opaque HTTP 500.
The silent case is the more dangerous: no exception anywhere, and the service method receives a value that
is not the one the caller passed. An application may already be storing altered data with no error having
been raised.
Every @RemoteService method taking a String — or any Datatype-formatted non-numeric argument whose
formatted form can contain " or \ — is affected. Arguments that only ever hold identifiers, table names
or job names are unaffected in practice, which is likely why this has gone unnoticed.
Root cause
The offending branch, from getParamsJson:
StringBuilder sb = new StringBuilder("{");
// per parameter:
String json;
if (value == null) {
json = "null";
} else if (EntityValues.isEntity(value)) {
json = entitySerialization.toJson(value); // properly serialized
} else {
Datatype<?> datatype = datatypeRegistry.find(value.getClass());
if (datatype != null) {
String formatted = datatype.format(value);
json = (value instanceof Boolean || value instanceof Number)
? formatted // unquoted
: "\"" + formatted + "\""; // <-- quoted by concatenation, NOT escaped
} else {
json = entitySerialization.objectToJson(value); // properly serialized
}
}
sb.append('"').append(param.getName()).append("\":").append(json);
For a String parameter, StringDatatype.format returns the value unchanged and it is then wrapped in
quotes by plain concatenation.
Note the asymmetry: entity arguments and arguments with no registered Datatype both go through
EntitySerialization, which escapes correctly. Only the Datatype branch — the one every String takes —
hand-rolls it.
Two related observations on the service side:
io.jmix.rest.impl.RestParseUtils#parseParamsJson parses with a lenient Gson reader
(JsonParser.parse(String) -> parseString -> parseReader). That is why raw newlines, tabs and other
control characters pass through intact where a compliant parser would reject them.
RestControllerExceptionHandler#handleException(Exception) answers a hardcoded "Server error" with an
empty details. Presumably deliberate, but it means a malformed-request bug in the client library's own
encoding is undiagnosable from the client — the only evidence is in the service application's log.
Steps To Reproduce
Service application:
@RestService("test_EchoService")
public class EchoRestService {
@RestMethod
public String echo(String text) {
return text;
}
}
UI application:
@RemoteService(store = "backend", remoteName = "test_EchoService")
public interface EchoRemoteService {
String echo(String text);
}
Call it with each of these and compare the returned value with the one passed in:
echoRemoteService.echo("plain"); // ok
echoRemoteService.echo("line1\nline2"); // ok, intact
echoRemoteService.echo("col1\tcol2"); // ok, intact
echoRemoteService.echo("say \"hi\""); // HTTP 500
echoRemoteService.echo("C:\\temp"); // returns C: + a REAL TAB + emp -- silently corrupted
echoRemoteService.echo("C:\\new"); // returns C: + a REAL LF + ew -- silently corrupted
echoRemoteService.echo("a\\\\b"); // returns a\b -- silently corrupted
echoRemoteService.echo("C:\\xyz"); // HTTP 500
echoRemoteService.echo("ends with\\"); // HTTP 500
echoRemoteService.echo("C:\\users"); // HTTP 500
Current Behavior
Output of exactly the calls above, run against Jmix 3.0.1:
| argument (Java literal) |
result |
"plain" |
ok |
"line1\nline2" |
ok, intact |
"col1\tcol2" |
ok, intact |
"a" + (char) 0x1E + "b" |
ok, intact |
"say \"hi\"" |
HTTP 500 |
"C:\\temp" |
silently corrupted — arrives as C:<TAB>emp |
"C:\\new" |
silently corrupted — arrives as C:<LF>ew |
"a\\\\b" |
silently corrupted — arrives as a\b |
"C:\\xyz" |
HTTP 500 (\x is not a valid JSON escape) |
"ends with\\" |
HTTP 500 (dangling escape) |
"C:\\users" |
HTTP 500 (\u expects four hex digits) |
A backslash corrupts or fails depending purely on the character that follows it, which is why the symptom
can look intermittent.
Service-application log for the HTTP 500 cases:
com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException:
Unterminated object at line 4 column 73 path $.transcript
at com.google.gson.internal.Streams.parse(Streams.java:58)
at com.google.gson.JsonParser.parseReader(JsonParser.java:144)
at com.google.gson.JsonParser.parseReader(JsonParser.java:110)
at com.google.gson.JsonParser.parseString(JsonParser.java:92)
at com.google.gson.JsonParser.parse(JsonParser.java:158)
at io.jmix.rest.impl.RestParseUtils.parseParamsJson(RestParseUtils.java:168)
at io.jmix.rest.impl.service.ServicesControllerManager.invokeServiceMethodPost(ServicesControllerManager.java:98)
at io.jmix.rest.impl.controller.ServicesController.invokeServiceMethodPost(ServicesController.java:44)
The service method is never invoked, so an application cannot handle this itself — catch (Throwable)
inside the @RestMethod is never reached.
What the calling application sees:
org.springframework.web.client.HttpServerErrorException$InternalServerError:
500 Internal Server Error: "{"error":"Server error","details":""}"
at io.jmix.restds.impl.service.RemoteServiceInvoker.invokeServiceMethod(RemoteServiceInvoker.java:62)
at io.jmix.restds.impl.service.RemoteServiceProxyFactoryBean.invokeServiceMethod(RemoteServiceProxyFactoryBean.java:78)
at io.jmix.restds.impl.service.RemoteServiceProxyFactoryBean.lambda$getObject$0(RemoteServiceProxyFactoryBean.java:65)
Expected Behavior
A String argument arrives at the service method exactly as it was passed, whatever characters it contains
— and in particular is never silently rewritten.
Suggested fix: serialize the parameter object with a JSON writer instead of concatenating strings. Building
a JsonObject/Map and letting Gson write it — or reusing EntitySerialization, which is already injected
into RemoteServiceInvoker — makes the escaping correct for every argument type by construction and removes
the need for the null/Boolean/Number special cases the hand-assembly currently requires.
A smaller change would be to escape formatted before wrapping it in quotes, but a writer is preferable.
It may also be worth reconsidering the lenient Gson reader in RestParseUtils#parseParamsJson: strict
parsing would reject a malformed body more clearly, and would not accept the raw control characters that
currently pass.
Workaround
Base64-encode the affected arguments at the call site and decode them in the service method. Base64's
alphabet contains neither character, so nothing on the wire can be misread. It changes the service's public
contract and has to be applied per method, but it is effective.
Sample Project
restds-string-escaping-sample.zip
Environment
Jmix version: 3.0.1
jmix-restds(client side),jmix-rest(service side)(
jmix.core.store-descriptor-backend=restds_RestDataStoreDescriptor)Bug Description
io.jmix.restds.impl.service.RemoteServiceInvoker#getParamsJsonbuilds the request body for a@RemoteServicecall by concatenating aStringBuilder, and does not JSON-escape the argument values itembeds. A
Stringargument is therefore reinterpreted as JSON source on the receiving side, with twodistinct consequences:
HTTP 500and an empty error message.value is silently altered and the call succeeds —
C:\temparrives asC:<TAB>emp. When it doesnot, the call fails with the same opaque
HTTP 500.The silent case is the more dangerous: no exception anywhere, and the service method receives a value that
is not the one the caller passed. An application may already be storing altered data with no error having
been raised.
Every
@RemoteServicemethod taking aString— or anyDatatype-formatted non-numeric argument whoseformatted form can contain
"or\— is affected. Arguments that only ever hold identifiers, table namesor job names are unaffected in practice, which is likely why this has gone unnoticed.
Root cause
The offending branch, from
getParamsJson:For a
Stringparameter,StringDatatype.formatreturns the value unchanged and it is then wrapped inquotes by plain concatenation.
Note the asymmetry: entity arguments and arguments with no registered
Datatypeboth go throughEntitySerialization, which escapes correctly. Only theDatatypebranch — the one everyStringtakes —hand-rolls it.
Two related observations on the service side:
io.jmix.rest.impl.RestParseUtils#parseParamsJsonparses with a lenient Gson reader(
JsonParser.parse(String)->parseString->parseReader). That is why raw newlines, tabs and othercontrol characters pass through intact where a compliant parser would reject them.
RestControllerExceptionHandler#handleException(Exception)answers a hardcoded"Server error"with anempty
details. Presumably deliberate, but it means a malformed-request bug in the client library's ownencoding is undiagnosable from the client — the only evidence is in the service application's log.
Steps To Reproduce
Service application:
UI application:
Call it with each of these and compare the returned value with the one passed in:
Current Behavior
Output of exactly the calls above, run against Jmix 3.0.1:
"plain""line1\nline2""col1\tcol2""a" + (char) 0x1E + "b""say \"hi\"""C:\\temp"C:<TAB>emp"C:\\new"C:<LF>ew"a\\\\b"a\b"C:\\xyz"\xis not a valid JSON escape)"ends with\\""C:\\users"\uexpects four hex digits)A backslash corrupts or fails depending purely on the character that follows it, which is why the symptom
can look intermittent.
Service-application log for the
HTTP 500cases:The service method is never invoked, so an application cannot handle this itself —
catch (Throwable)inside the
@RestMethodis never reached.What the calling application sees:
Expected Behavior
A
Stringargument arrives at the service method exactly as it was passed, whatever characters it contains— and in particular is never silently rewritten.
Suggested fix: serialize the parameter object with a JSON writer instead of concatenating strings. Building
a
JsonObject/Mapand letting Gson write it — or reusingEntitySerialization, which is already injectedinto
RemoteServiceInvoker— makes the escaping correct for every argument type by construction and removesthe need for the
null/Boolean/Numberspecial cases the hand-assembly currently requires.A smaller change would be to escape
formattedbefore wrapping it in quotes, but a writer is preferable.It may also be worth reconsidering the lenient Gson reader in
RestParseUtils#parseParamsJson: strictparsing would reject a malformed body more clearly, and would not accept the raw control characters that
currently pass.
Workaround
Base64-encode the affected arguments at the call site and decode them in the service method. Base64's
alphabet contains neither character, so nothing on the wire can be misread. It changes the service's public
contract and has to be applied per method, but it is effective.
Sample Project
restds-string-escaping-sample.zip