Skip to content

restds: RemoteService string arguments are not JSON-escaped #5643

Description

@syncro

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:

  1. A double quote terminates the JSON string early, the body is malformed, and the call fails with
    HTTP 500 and an empty error message.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions