Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions firebase-database/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Unreleased

- [fixed] Fixed a crash when the Realtime Database server sent a message with
a missing or malformed field. The SDK now fails the affected request, ignores
the message, or reconnects instead. (#8764)

# 23.0.0

- [changed] **Breaking Change**: Updated minSdkVersion to API level 24 or higher. (#8638)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package com.google.firebase.database.connection;

import com.google.firebase.database.logging.LogWrapper;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -143,8 +144,12 @@ public void onMessage(Map<String, Object> message) {
"Failed to parse server message: missing message type:" + message.toString());
close();
}
} catch (ClassCastException e) {
if (logger.logsDebug()) logger.debug("Failed to parse server message: " + e.toString());
} catch (RuntimeException e) {
// All handling of a server message, including the persistent connection's, runs
// synchronously from here. An unexpected message must not throw into the run loop, which
// would crash the app. Close the connection instead, so that the persistent connection
// reconnects with its usual backoff.
logger.warn("Failed to process server message, closing the connection", e);
close();
}
}
Expand Down Expand Up @@ -190,8 +195,8 @@ private void onControlMessage(Map<String, Object> data) {
if (logger.logsDebug()) logger.debug("Got invalid control message: " + data.toString());
close();
}
} catch (ClassCastException e) {
if (logger.logsDebug()) logger.debug("Failed to parse control message: " + e.toString());
} catch (RuntimeException e) {
logger.warn("Failed to process control message, closing the connection", e);
close();
}
}
Expand All @@ -203,17 +208,39 @@ private void onConnectionShutdown(String reason) {
}

private void onHandshake(Map<String, Object> handshake) {
long timestamp = (Long) handshake.get(SERVER_HELLO_TIMESTAMP);
String host = (String) handshake.get(SERVER_HELLO_HOST);
if (handshake == null) {
// Every field of the handshake is optional for the client, so treat a missing payload like
// an empty one.
handshake = Collections.emptyMap();
}
long timestamp = getServerTimestamp(handshake);
Comment thread
paulb777 marked this conversation as resolved.
Resolved
Object hostObj = handshake.get(SERVER_HELLO_HOST);
String host = hostObj instanceof String ? (String) hostObj : null;
delegate.onCacheHost(host);
String sessionId = (String) handshake.get(SERVER_HELLO_SESSION_ID);
Object sessionIdObj = handshake.get(SERVER_HELLO_SESSION_ID);
String sessionId = sessionIdObj instanceof String ? (String) sessionIdObj : null;

if (state == State.REALTIME_CONNECTING) {
conn.start();
onConnectionReady(timestamp, sessionId);
}
}

/**
* Returns the server time from the handshake, which the client uses to compute the offset between
* the server clock and the local clock. If the server did not send a usable timestamp, this
* returns the local time instead, so that the client assumes no offset and the connection still
* works.
*/
private long getServerTimestamp(Map<String, Object> handshake) {
Object timestamp = handshake.get(SERVER_HELLO_TIMESTAMP);
if (timestamp instanceof Number) {
return ((Number) timestamp).longValue();
}
logger.warn("Server handshake did not include a valid timestamp, using the local time");
return System.currentTimeMillis();
}

private void onConnectionReady(long timestamp, String sessionId) {
if (logger.logsDebug()) logger.debug("realtime connection established");
state = State.REALTIME_CONNECTED;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ private enum ConnectionState {
private static final String SERVER_RESPONSE_DATA = "d";
private static final String INVALID_APP_CHECK_TOKEN = "Invalid appcheck token";

/**
* Status used for a server response that does not include a valid status. It is not a status the
* server sends, so it is reported to callers as an unknown error.
*/
private static final String INVALID_RESPONSE_STATUS = "invalid_response";

/** Delay after which a established connection is considered successful */
private static final long SUCCESSFUL_CONNECTION_ESTABLISHED_DELAY = 30 * 1000;

Expand Down Expand Up @@ -417,12 +423,17 @@ public Task<Object> get(List<String> path, Map<String, Object> queryParams) {
REQUEST_ACTION_GET,
request,
response -> {
String status = (String) response.get(REQUEST_STATUS);
if (status.equals("ok")) {
String status = getResponseStatus(response);
if ("ok".equals(status)) {
Object body = response.get(SERVER_DATA_UPDATE_BODY);
source.setResult(body);
} else {
source.setException(new Exception((String) response.get(SERVER_DATA_UPDATE_BODY)));
String errorMessage = getString(response, SERVER_DATA_UPDATE_BODY);
source.setException(
new Exception(
errorMessage != null
? errorMessage
: "Get request failed with status: " + status));
}
});
outstandingGets.put(readId, outstandingGet);
Expand Down Expand Up @@ -491,8 +502,12 @@ public void onDataMessage(Map<String, Object> message) {
ConnectionRequestCallback responseListener = requestCBHash.remove(rn);
if (responseListener != null) {
// jackson gives up Map<String, Object> for json objects
Object body = message.get(RESPONSE_FOR_REQUEST);
// A response without a valid body is handled like an empty one, which the request
// callbacks treat as a failed request.
@SuppressWarnings("unchecked")
Map<String, Object> response = (Map<String, Object>) message.get(RESPONSE_FOR_REQUEST);
Map<String, Object> response =
body instanceof Map ? (Map<String, Object>) body : Collections.emptyMap();
responseListener.onResponse(response);
}
} else if (message.containsKey(REQUEST_ERROR)) {
Expand Down Expand Up @@ -537,7 +552,7 @@ public void onDisconnect(Connection.DisconnectReason reason) {

@Override
public void onKill(String reason) {
if (reason.equals(INVALID_APP_CHECK_TOKEN)
if (INVALID_APP_CHECK_TOKEN.equals(reason)
&& invalidAppCheckTokenCount < INVALID_TOKEN_THRESHOLD) {
invalidAppCheckTokenCount++;
logger.warn(
Expand Down Expand Up @@ -847,12 +862,12 @@ private void sendOnDisconnect(
new ConnectionRequestCallback() {
@Override
public void onResponse(Map<String, Object> response) {
String status = (String) response.get(REQUEST_STATUS);
String status = getResponseStatus(response);
String errorMessage = null;
String errorCode = null;
if (!status.equals("ok")) {
if (!"ok".equals(status)) {
errorCode = status;
errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY);
errorMessage = getString(response, SERVER_DATA_UPDATE_BODY);
}
if (onComplete != null) {
onComplete.onRequestResult(errorCode, errorMessage);
Expand Down Expand Up @@ -931,6 +946,10 @@ private Collection<OutstandingListen> removeListens(List<String> path) {

private void onDataPush(String action, Map<String, Object> body) {
if (logger.logsDebug()) logger.debug("handleServerMessage: " + action + " " + body);
if (!isWellFormedPush(action, body)) {
logger.warn("Ignoring malformed server push for action: " + action);
return;
}
if (action.equals(SERVER_ASYNC_DATA_UPDATE) || action.equals(SERVER_ASYNC_DATA_MERGE)) {
boolean isMerge = action.equals(SERVER_ASYNC_DATA_MERGE);

Expand Down Expand Up @@ -984,6 +1003,59 @@ private void onDataPush(String action, Map<String, Object> body) {
}
}

/**
* Returns whether a push from the server has the fields and field types that {@link #onDataPush}
* casts without further checks.
*/
private static boolean isWellFormedPush(String action, Map<String, Object> body) {
if (action == null || body == null) {
return false;
}
boolean requiresPath =
action.equals(SERVER_ASYNC_DATA_UPDATE)
|| action.equals(SERVER_ASYNC_DATA_MERGE)
|| action.equals(SERVER_ASYNC_DATA_RANGE_MERGE)
|| action.equals(SERVER_ASYNC_LISTEN_CANCELLED);
if (requiresPath && !(body.get(SERVER_DATA_UPDATE_PATH) instanceof String)) {
return false;
}
if (action.equals(SERVER_ASYNC_DATA_MERGE)) {
return body.get(SERVER_DATA_UPDATE_BODY) instanceof Map;
}
if (action.equals(SERVER_ASYNC_DATA_RANGE_MERGE)) {
Object payload = body.get(SERVER_DATA_UPDATE_BODY);
if (!(payload instanceof List)) {
return false;
}
for (Object item : (List<?>) payload) {
if (!(item instanceof Map)) {
return false;
}
Map<?, ?> range = (Map<?, ?>) item;
Object start = range.get(SERVER_DATA_START_PATH);
if (start != null && !(start instanceof String)) {
return false;
}
Object end = range.get(SERVER_DATA_END_PATH);
if (end != null && !(end instanceof String)) {
return false;
}
}
return true;
}
if (action.equals(SERVER_ASYNC_AUTH_REVOKED) || action.equals(SERVER_ASYNC_APP_CHECK_REVOKED)) {
Object status = body.get(REQUEST_STATUS);
Object reason = body.get(SERVER_DATA_UPDATE_BODY);
return (status == null || status instanceof String)
&& (reason == null || reason instanceof String);
}
if (action.equals(SERVER_ASYNC_SECURITY_DEBUG)) {
Object msg = body.get("msg");
return msg == null || msg instanceof String;
}
return true;
Comment thread
paulb777 marked this conversation as resolved.
Resolved
}
Comment thread
paulb777 marked this conversation as resolved.
Resolved

private void onListenRevoked(List<String> path) {
// Remove the listen and manufacture a "permission denied" error for the failed listen

Expand Down Expand Up @@ -1039,20 +1111,20 @@ private void sendAuthHelper(final boolean restoreStateAfterComplete) {
new ConnectionRequestCallback() {
@Override
public void onResponse(Map<String, Object> response) {
String status = (String) response.get(REQUEST_STATUS);
if (status.equals("ok")) {
String status = getResponseStatus(response);
if ("ok".equals(status)) {
connectionState = ConnectionState.Connected;
invalidAuthTokenCount = 0;
sendAppCheckTokenHelper(restoreStateAfterComplete);
} else {
authToken = null;
forceAuthTokenRefresh = true;
delegate.onConnectionStatus(false);
String reason = (String) response.get(SERVER_RESPONSE_DATA);
String reason = getString(response, SERVER_RESPONSE_DATA);
logger.debug("Authentication failed: " + status + " (" + reason + ")");
realtime.close();

if (status.equals("invalid_token")) {
if ("invalid_token".equals(status)) {
// We'll wait a couple times before logging the warning / increasing the
// retry period since oauth tokens will report as "invalid" if they're
// just expired. Plus there may be transient issues that resolve themselves.
Expand Down Expand Up @@ -1098,13 +1170,13 @@ private void sendAppCheckTokenHelper(final boolean restoreStateAfterComplete) {

ConnectionRequestCallback onComplete =
response -> {
String status = (String) response.get(REQUEST_STATUS);
if (status.equals("ok")) {
String status = getResponseStatus(response);
if ("ok".equals(status)) {
invalidAppCheckTokenCount = 0;
} else {
appCheckToken = null;
forceAppCheckTokenRefresh = true;
String reason = (String) response.get(SERVER_RESPONSE_DATA);
String reason = getString(response, SERVER_RESPONSE_DATA);
logger.debug("App check failed: " + status + " (" + reason + ")");
// Note: We don't close the connection as the developer may not have
// enforcement enabled. The backend closes connections with enforcements.
Expand Down Expand Up @@ -1253,11 +1325,11 @@ public void onResponse(Map<String, Object> response) {
outstandingPuts.remove(putId);

if (onComplete != null) {
String status = (String) response.get(REQUEST_STATUS);
if (status.equals("ok")) {
String status = getResponseStatus(response);
if ("ok".equals(status)) {
onComplete.onRequestResult(null, null);
} else {
String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY);
String errorMessage = getString(response, SERVER_DATA_UPDATE_BODY);
onComplete.onRequestResult(status, errorMessage);
}
}
Expand Down Expand Up @@ -1331,25 +1403,27 @@ private void sendListen(final OutstandingListen listen) {

@Override
public void onResponse(Map<String, Object> response) {
String status = (String) response.get(REQUEST_STATUS);
String status = getResponseStatus(response);
// log warnings in any case, even if listener was already removed
if (status.equals("ok")) {
@SuppressWarnings("unchecked")
Map<String, Object> serverBody =
(Map<String, Object>) response.get(SERVER_DATA_UPDATE_BODY);
if (serverBody.containsKey(SERVER_DATA_WARNINGS)) {
@SuppressWarnings("unchecked")
List<String> warnings = (List<String>) serverBody.get(SERVER_DATA_WARNINGS);
warnOnListenerWarnings(warnings, listen.query);
if ("ok".equals(status)) {
// The body and the warnings in it are optional.
Object serverBody = response.get(SERVER_DATA_UPDATE_BODY);
if (serverBody instanceof Map) {
Object warnings = ((Map<?, ?>) serverBody).get(SERVER_DATA_WARNINGS);
if (warnings instanceof List) {
@SuppressWarnings("unchecked")
List<String> warningList = (List<String>) warnings;
warnOnListenerWarnings(warningList, listen.query);
}
}
}

OutstandingListen currentListen = listens.get(listen.getQuery());
// only trigger actions if the listen hasn't been removed (and maybe readded)
if (currentListen == listen) {
if (!status.equals("ok")) {
if (!"ok".equals(status)) {
removeListen(listen.getQuery());
String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY);
String errorMessage = getString(response, SERVER_DATA_UPDATE_BODY);
listen.resultCallback.onRequestResult(status, errorMessage);
} else {
listen.resultCallback.onRequestResult(null, null);
Expand All @@ -1369,9 +1443,9 @@ private void sendStats(final Map<String, Integer> stats) {
new ConnectionRequestCallback() {
@Override
public void onResponse(Map<String, Object> response) {
String status = (String) response.get(REQUEST_STATUS);
if (!status.equals("ok")) {
String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY);
String status = getResponseStatus(response);
if (!"ok".equals(status)) {
String errorMessage = getString(response, SERVER_DATA_UPDATE_BODY);
if (logger.logsDebug()) {
logger.debug(
"Failed to send stats: " + status + " (message: " + errorMessage + ")");
Expand Down Expand Up @@ -1428,6 +1502,28 @@ private void sendSensitive(
requestCBHash.put(rn, onResponse);
}

/**
* Returns the status of a response from the server. A response without a valid status is treated
* as a failed request, so this never returns null.
*/
private String getResponseStatus(Map<String, Object> response) {
String status = getString(response, REQUEST_STATUS);
if (status == null) {
logger.warn("Server response did not include a valid status, treating the request as failed");
return INVALID_RESPONSE_STATUS;
}
return status;
}

/** Returns the value for {@code key} if it is a string, or null otherwise. */
private static String getString(Map<String, Object> message, String key) {
if (message == null) {
return null;
}
Object value = message.get(key);
return value instanceof String ? (String) value : null;
}
Comment thread
paulb777 marked this conversation as resolved.
Resolved

private long nextRequestNumber() {
return requestCounter++;
}
Expand Down
Loading
Loading