Skip to content

Check for JSONObject.NULL before casting to String [Fixes #209] #723

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

Merged
merged 1 commit into from
Jan 22, 2018

Conversation

jhansche
Copy link
Contributor

The same technique is already used in ParseObject.getString(), but because this is a ParseObject.State subclass, there is no getString() method. The documentation explicitly states that JSONObject.NULL may be contained in the data.

Before the change, the unit test fails with:

com.parse.ParseUserTest > testSessionTokenFromNull FAILED
    java.lang.ClassCastException: org.json.JSONObject$1 cannot be cast to java.lang.String
        at com.parse.ParseUser$State.sessionToken(ParseUser.java:139)
        at com.parse.ParseUser.getSessionToken(ParseUser.java:313)
        at com.parse.ParseUserTest.testSessionTokenFromNull(ParseUserTest.java:137)

@codecov
Copy link

codecov bot commented Aug 24, 2017

Codecov Report

Merging #723 into master will decrease coverage by 0.01%.
The diff coverage is 100%.

Impacted file tree graph

@@             Coverage Diff              @@
##             master     #723      +/-   ##
============================================
- Coverage     53.43%   53.42%   -0.02%     
- Complexity     1747     1748       +1     
============================================
  Files           132      132              
  Lines         10293    10295       +2     
  Branches       1427     1428       +1     
============================================
  Hits           5500     5500              
- Misses         4339     4340       +1     
- Partials        454      455       +1
Impacted Files Coverage Δ Complexity Δ
Parse/src/main/java/com/parse/ParseDecoder.java 97.01% <100%> (+0.09%) 24 <0> (+1) ⬆️
...se/src/main/java/com/parse/ParseKeyValueCache.java 45% <0%> (-2%) 16% <0%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3cc9570...c575524. Read the comment docs.

@natario1
Copy link
Contributor

I am looking at this and am trying to identify the issue... It would be better to catch the bug rather than do a check because there's a bug somewhere.

Session token is read only, if you attempt to call object.put("sessionToken", ...) the sdk will throw no matter the content. So JsonObject.NULL comes from the SDK itself and that's an error

@jhansche
Copy link
Contributor Author

jhansche commented Aug 24, 2017

Well, even ParseObject.mergeFromServer() may blindly insert a non-String object into that key -- and I'm sure there are plenty of other places as well that may do that.

We could add some debugging code to ParseUser.State.Builder.put(String,Object) to surface the root cause, but I haven't been able to reproduce the issue myself, so I'm not sure how long that debugging code would have to be in place before the root cause is identified. If it's as simple as returning an otherwise valid ParseUser object, but with sessionToken=null in the JSON payload, then I can put together a test to see if I can reproduce the cast issue.

Session token is read only, if you attempt to call object.put("sessionToken", ...) the sdk will throw no matter the content.

Where do you see that? I don't see any checks for put() where key == sessionToken.

@natario1
Copy link
Contributor

I have a strong feeling that something wrong is happing in this class.

But before fixing I am trying to write a failing test. If anyone would like to help investigating, it would be great.

@jhansche
Copy link
Contributor Author

@natario1 OK, I was able to reproduce the issue by using Charles Proxy to rewrite the server response of the /users/me API call. The rewrite replaces the "sessionToken":"r:..." JSON string with "sessionToken":null, and 💥 the same crash mentioned in #209 happens immediately after the become() call:

java.lang.ClassCastException: org.json.JSONObject$1 cannot be cast to java.lang.String
    at com.parse.ParseUser$State.sessionToken(ParseUser.java:134)
    at com.parse.ParseUser.isAuthenticated(ParseUser.java:206)

I'll build in the put() override mentioned above, so I can trace it back to wherever the value is getting set.

Note that I do not think the class you linked to is relevant. The first thing I notice is that ParseCurrentUserCoder's KEY_SESSION_TOKEN key name is session_token, which does not match the sessionToken key used elsewhere.

@natario1
Copy link
Contributor

@jhansche that's cool, but I don't think parse-server is ever returning null session tokens. Even automatic users have one, right?

@natario1
Copy link
Contributor

I really think it's the coder I mentioned. This reproduces the error.

  @Test
  public void testEncodeDecodeWithNullSessionToken() throws Exception {
    ParseUser.State state = new ParseUser.State.Builder()
        .sessionToken(null)
        .build();
    ParseUserCurrentCoder coder = ParseUserCurrentCoder.get();
    JSONObject object = coder.encode(state, null, PointerEncoder.get());
    ParseUser.State.Builder builder =
            coder.decode(new ParseUser.State.Builder(), object, ParseDecoder.get());
    state = builder.build();
    assertNull(state.sessionToken());
  }

Going to set up a fix... Thanks for helping @jhansche

@jhansche
Copy link
Contributor Author

I added the ParseUser.State.Builder.put() override mentioned above, and this is the stack trace I get when that value is being put into the ParseUser.State map:

java.lang.IllegalArgumentException: Cannot receive NULL here
    at com.parse.ParseUser$State$Builder.put(ParseUser.java:103)
    at com.parse.ParseUser$State$Builder.put(ParseUser.java:66)
    at com.parse.ParseObjectCoder.decode(ParseObjectCoder.java:127)
    at com.parse.NetworkUserController$5.then(NetworkUserController.java:130)
    at com.parse.NetworkUserController$5.then(NetworkUserController.java:125)

@jhansche
Copy link
Contributor Author

OK, so you think it's when persisting to disk and then restoring from disk? The docs for ParseCurrentUserCoder suggest it will only be used when LDS is not enabled. I do use LDS, so I'm not sure if that's the same issue that my app is running into.

The problem with PCUC that you mentioned, is that it only removes the value if it was non-null, but it will leave the key if it did exist but contained a null-like value, like JSONObject.NULL

    String newSessionToken = json.optString(KEY_SESSION_TOKEN, null);
    if (newSessionToken != null) {
      userBuilder.sessionToken(newSessionToken);
      json.remove(KEY_SESSION_TOKEN);
    }

Similarly, the encode() method lets the super class encode everything into the JSONObject, and then only replaces the session token key if it was non-null, but will not remove the JSONObject.NULL if it had already been encoded that way.

@jhansche
Copy link
Contributor Author

With the same put() override and running your test, this is the stacktrace:

java.lang.IllegalArgumentException: Cannot receive NULL here
	at com.parse.ParseUser$State$Builder.put(ParseUser.java:104)
	at com.parse.ParseUser$State$Builder.put(ParseUser.java:66)
	at com.parse.ParseObjectCurrentCoder.decode(ParseObjectCurrentCoder.java:175)
	at com.parse.ParseUserCurrentCoder.decode(ParseUserCurrentCoder.java:120)
	at com.parse.ParseObjectCurrentCoderTest.testEncodeDecodeWithNullSessionToken(ParseObjectCurrentCoderTest.java:79)

But I think the documentation for ParseUserCurrentCoder is incorrect then, because the coder is still used even with local datastore enabled:

      FileObjectStore<ParseUser> fileStore =
          new FileObjectStore<>(ParseUser.class, file, ParseUserCurrentCoder.get());
      ParseObjectStore<ParseUser> store = Parse.isLocalDatastoreEnabled()
          ? new OfflineObjectStore<>(ParseUser.class, PIN_CURRENT_USER, fileStore)
          : fileStore;
      ParseCurrentUserController controller = new CachedCurrentUserController(store);

But OfflineObjectStore seems to only use the passed-in legacy store in order to migrate old data to the new offline store.

@natario1
Copy link
Contributor

I have created #724 . Yes, exactly... So you are experiencing this with LDS enabled?

@jhansche
Copy link
Contributor Author

Yes, my app is seeing the same crash with LDS enabled.

@natario1
Copy link
Contributor

OK, sorry, didn't read. I think this was the root cause of the problem. But the fact that you are experiencing it with LDS enabled is confusing. OfflineObjectStore contains the file store, but never writes to it

@jhansche
Copy link
Contributor Author

Another change that fixes it across all cases, without having to check it on the way out:

      @Override
      public Builder put(String key, Object value) {
        if (value == JSONObject.NULL) value = null;
        return super.put(key, value);
      }

What's the reason for ever keeping JSONObject.NULL?

@flovilmart
Copy link
Contributor

@natario1 we're seeing that issue too, randomly.

@natario1
Copy link
Contributor

natario1 commented Dec 3, 2017

@flovilmart do you have traces? There's another bug somewhere then. If it's very frequent we could merge this, it's just not elegant at all, like a try catch to hide a broken contract.

@flovilmart
Copy link
Contributor

Yeah, we’ll investigate more Monday, we don’t have other traces besides what’s there already. I’d like us not to merge this one as it’s pushing the dust under the carpet.

@CoderickLamar
Copy link

CoderickLamar commented Jan 16, 2018

Hi All,

I am running into this issue as well with LDS enabled.

Currently running Android SDK: 1.16.3

Here is my stack trace:

Fatal Exception: java.lang.ClassCastException: org.json.JSONObject$1 cannot be cast to java.lang.String
       at com.parse.ParseUser$State.sessionToken(ParseUser.java:144)
       at com.parse.ParseUser.getSessionToken(ParseUser.java:324)
       at com.parse.NetworkQueryController.findAsync(NetworkQueryController.java:34)
       at com.parse.OfflineQueryController.findAsync(OfflineQueryController.java:34)
       at com.parse.AbstractQueryController.getFirstAsync(AbstractQueryController.java:25)
       at com.parse.ParseQuery.getFirstAsync(ParseQuery.java:1291)
       at com.parse.ParseQuery.access$1900(ParseQuery.java:90)
       at com.parse.ParseQuery$5$1.then(ParseQuery.java:1283)
       at com.parse.ParseQuery$5$1.then(ParseQuery.java:1279)
       at bolts.Task$15.run(Task.java:917)
       at bolts.BoltsExecutors$ImmediateExecutor.execute(BoltsExecutors.java:105)
       at bolts.Task.completeAfterTask(Task.java:908)
       at bolts.Task.continueWithTask(Task.java:715)
       at bolts.Task.continueWithTask(Task.java:726)
       at bolts.Task$13.then(Task.java:818)
       at bolts.Task$13.then(Task.java:806)
       at bolts.Task$15.run(Task.java:917)
       at bolts.BoltsExecutors$ImmediateExecutor.execute(BoltsExecutors.java:105)
       at bolts.Task.completeAfterTask(Task.java:908)
       at bolts.Task.continueWithTask(Task.java:715)
       at bolts.Task.continueWithTask(Task.java:690)
       at bolts.Task.onSuccessTask(Task.java:806)
       at bolts.Task.onSuccessTask(Task.java:796)
       at bolts.Task.onSuccessTask(Task.java:830)
       at com.parse.ParseQuery$5.call(ParseQuery.java:1279)
       at com.parse.ParseQuery$5.call(ParseQuery.java:1276)
       at com.parse.ParseQuery.perform(ParseQuery.java:1151)
       at com.parse.ParseQuery.getFirstAsync(ParseQuery.java:1276)
       at com.parse.ParseQuery.getFirstInBackground(ParseQuery.java:1240)
       at com.parse.ParseQuery.getFirst(ParseQuery.java:1022)

All of this is originating from:

ParseQuery.getQuery(XXXXXXXXXX.class).getFirst();

@flovilmart
Copy link
Contributor

@jhansche We're jumping back on this one.

What's the reason for ever keeping JSONObject.NULL?

I don't believe we should ever have a JSONObject.NULL. How does it handle when you put a null value in the database?

does it interpoate to null?

@natario1
Copy link
Contributor

The source of the bug is probably a encode/decode cycle, in which a legit null value gets encoded to JsonObject.NULL for storing (more or less), and then it is not correctly decoded to null again.

I fixed the LDS-off decoder in #724 , I would expect a similar issue somewhere in LDS-on classes... But can't help more than this

@jhansche
Copy link
Contributor Author

jhansche commented Jan 16, 2018

It would help to be able to track down how that JsonObject.NULL value gets into the map. Given that we see this crash on the way out anyway, is there any objection to crashing loudly in put() using code similar to what I mentioned in #723 (comment)?
That should at least give us some stack traces to figure out where it's coming from. Even if that's only temporary until we find the root cause and fix it.

BTW, maybe we should reopen #209 if this still an issue?

@flovilmart
Copy link
Contributor

@jhansche as it stands now, we could harden the logic around the sessionToken setting, from null to check also for JSONObject.NULL. I'm not a big fan of preventing the put of JSONObject.NULL at large as it hides a bigger issue. I believe we're on the right track, with the serializer into SQLite. Perhaps at one point, because a key is missing (with anon users) the session token is stored as null and not 'undefined'. When unserializing from the LDS' this may cause the JSON.NULL to pop in.

@CoderickLamar
Copy link

CoderickLamar commented Jan 16, 2018

@flovilmart
Our app has anon users disabled.

@jhansche
Copy link
Contributor Author

@flovilmart the reason blocking it at put() would be helpful is because we'll have a stacktrace showing when/how/why the value is being put into the map in the first place. As it is, the only stacktrace we have to go on currently is the CCE on the way out, and that is what is "hiding the bigger issue." Rejecting the put() would surface how that value is getting into the map in the first place, which will lead us to the root cause, and ultimately the right way to fix it.

Right now I see a lot of "maybe" and "might" and "perhaps", but no suggestions for proving any of those theories.

@flovilmart
Copy link
Contributor

Anyway we can instrument the code with a proper printStackTrace if it ever happens? Right now we don’t know what puts it in, wether it’a malformed parse server response or a serialization issue? I’d be comfortable with having it at the put, preventing it, and print a stacktrace that would be easily reported

@jhansche
Copy link
Contributor Author

Right, that's what I'm talking about. My suggestion was to throw an exception (which will of course include the stacktrace), but if you'd rather just log it, that's at least a way to make an informed decision. Throwing the exception will be more noticeable (because it will crash) and it is more likely to get reported here with the stacktrace. Simply logging the exception would require pairing the #209 crash, with an earlier stacktrace that got printed to the logs, and that might make it more difficult to track down the root cause.

@CoderickLamar
Copy link

Can you direct me to the line number where you're suggesting we put it?

I can go ahead and push it through our app (since we haven't been able to repro this locally), and I can attempt to get it logged remotely to see what we come up with.

@mmimeault
Copy link
Contributor

Same for us (me and @flovilmart). @jhansche I am looking at the SDK right now, but I'm not totally sure where I would put this log/debug/crash to be able to prove who is saving this JSON.Null. Thanks for you help.

@jhansche
Copy link
Contributor Author

jhansche commented Jan 18, 2018

From #723 (comment)
I suggested overriding ParseUser.State.Builder.put() to add the check:

java.lang.IllegalArgumentException: Cannot receive NULL here
	at com.parse.ParseUser$State$Builder.put(ParseUser.java:104)
	at com.parse.ParseUser$State$Builder.put(ParseUser.java:66)

@flovilmart
Copy link
Contributor

I’ll also double check that the server never send out null in any case for that particular key.

@mmimeault
Copy link
Contributor

I added a stacktrace logger into this method:
https://github.com/parse-community/Parse-SDK-Android/blob/master/Parse/src/main/java/com/parse/ParseObject.java#L978-L1052
(I pushed that before seeing @jhansche , but at the end it does the same, since I added my log just before the builder.put, but thanks)

So here is the stacktrace:

       at com.parse.ParseObject.mergeFromServer(ParseObject.java:1045)
       at com.parse.ParseObject.mergeREST(ParseObject.java:1202)
       at com.parse.OfflineStore$11$2.then(OfflineStore.java:669)
       at com.parse.OfflineStore$11$2.then(OfflineStore.java:666)
       at bolts.i$5.run(Task.java:872)
       at bolts.c$a.execute(BoltsExecutors.java:105)
       at bolts.i.c(Task.java:863)
       at bolts.i.a(Task.java:661)
       at bolts.i.a(Task.java:672)
       at bolts.i$3.a(Task.java:766)
       at bolts.i$3.then(Task.java:754)
       at bolts.i$6.run(Task.java:917)
       at bolts.c$a.execute(BoltsExecutors.java:105)
       at bolts.i.d(Task.java:908)
       at bolts.i.b(Task.java:715)
       at bolts.i.b(Task.java:690)
       at bolts.i.c(Task.java:754)
       at bolts.i.c(Task.java:778)
       at com.parse.OfflineStore$11.then(OfflineStore.java:666)
       at com.parse.OfflineStore$11.then(OfflineStore.java:626)
........

@mmimeault
Copy link
Contributor

Maybe what I could do is put the stacktrace logging where @jhansche suggested, so I would know who is trying to set a null session in the State.
I'm not sure this is normal to have a null session token.

@flovilmart
Copy link
Contributor

@mmimeault the session token can be null if the user was never saved.

@jhansche
Copy link
Contributor Author

jhansche commented Jan 19, 2018

@flovilmart to be clear, the problem is not whether null gets set into the map. The problem is when JsonObject.NULL is set, instead of a proper null. So setting null is fine, but it should log/crash/report/whatever if anyone tries to put JsonObject.NULL in the map. Because that means the json null did not get decoded properly.

@mmimeault is that stack trace what you got when encountering the error in your live app environment?

@jhansche
Copy link
Contributor Author

jhansche commented Jan 19, 2018

https://android.googlesource.com/platform/libcore/+/master/json/src/main/java/org/json/JSONObject.java#100

Looking at ParseDecoder.decode I see:

    if (!(object instanceof JSONObject)) {
      return object;
    }

And because JSONObject.NULL is declared as an anonymous Object{} (or in other sources, as a final class JSONObject.Null{}), it is not a subclass of JSONObject, so it returns JSONObject.NULL directly.

So the problem is that it doesn't convert JSONObject.NULL to null.

ParseEncoder.encode does convert null => JSONObject.NULL, but ParseDecoder does not do the inverse. It's a ParseDecoder bug.

@flovilmart
Copy link
Contributor

This is where we converge as for the core reason for the issue, @mmimeault has an app going live soon with extra tooling in there. let's see the results of that and try to release something before next week.

ParseEncoder encodes (null) => JSONObject.NULL. But ParseDecoder was not
doing the inverse conversion.

That resulted in JSONObject.NULL values being in the ParseObject.State map,
which means any calls to State.get(key) that were casting the result to a
specific type (i.e., String) without first checking the Object return type
would result in a ClassCastException.

ParseObject already accounts for this possibility by checking the Object type
before casting it, and returning null if the type did not match.

The known case that causes crashes is parse-community#209, where ParseUser.State.sessionToken()
casts the result of get(KEY_SESSION_TOKEN) to String. When that was null it
would be encoded as JSONObject.NULL due to the ParseEncoder; but after decoding
it would not convert back to a native null, leading to the ClassCastException.
@mmimeault
Copy link
Contributor

Ok so well, your solution @jhansche sounds good.

I applied the same fixes to a production build with a lot of logging. It seem to be when you logout and then relaunch the app following that logout. The logout persist a null token as I can see. (validate me). I haven't see any other case where the token was set to null from the logs I got during the weekend.

The SDK explicitly encode null to JSONObject.NULL, but do not handle the decode part an return directly that object per

  if (!(object instanceof JSONObject)) {
    return object;
  }

So from what I understand, it is totally possible to have a null session token in a some particular case. Saving them a JSONObject.NULL will always result into a crash when trying to retrieve that particular token.

So I'm fine to go with your solution/implementation that is pending since August.

@flovilmart --^

@mmimeault mmimeault merged commit 0324c21 into parse-community:master Jan 22, 2018
bitterbit pushed a commit to bitterbit/Parse-SDK-Android that referenced this pull request Mar 9, 2018
Check for JSONObject.NULL before casting to String [Fixes parse-community#209]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants