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 host or
session ID that wasn't valid in a URL. The SDK now ignores the value.
(#8780)

# 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 @@ -221,6 +221,15 @@ private void onConnectionReady(long timestamp, String sessionId) {
}

private void onReset(String host) {
if (host != null && !hostInfo.isUsableHost(host)) {
// Ignore the host and keep the current one. The server is likely to reset the next
// connection the same way, so close with OTHER instead of SERVER_RESET: the persistent
// connection then reconnects with its usual backoff instead of immediately, which avoids a
// tight reconnect loop.
logger.warn("Got a reset with a host that can't be used in a URL, ignoring the host");
close(DisconnectReason.OTHER);
return;
}
if (logger.logsDebug())
logger.debug(
"Got a reset; killing connection to "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

package com.google.firebase.database.connection;

import androidx.annotation.Nullable;
import com.google.firebase.database.logging.LogWrapper;
import java.net.URI;

public class HostInfo {
Expand Down Expand Up @@ -55,6 +57,60 @@ public static URI getConnectionUrl(
return URI.create(url);
}

/**
* Returns the URL for a new connection to this database.
*
* <p>The cached host and the last session ID were sent by the server. If one of them can't be
* used in the URL, it is ignored with a warning: the connection goes to the configured host, or
* starts without a last session ID. Each value is checked on its own, so that a bad session ID
* doesn't also discard a good host. Otherwise, if the configured host reset the connection to
* that host, the client would reconnect in a tight loop.
*/
URI getConnectionUrl(
@Nullable String optCachedHost, @Nullable String optLastSessionId, LogWrapper logger) {
String connectionHost = host;
if (optCachedHost != null) {
if (isUsableHost(optCachedHost)) {
connectionHost = optCachedHost;
} else {
logger.warn(
"Ignoring a host from the server that can't be used in a URL, connecting to the"
+ " configured host instead");
}
}
if (optLastSessionId != null) {
URI url = tryGetConnectionUrl(connectionHost, optLastSessionId);
if (url != null) {
return url;
}
logger.warn("Ignoring a session ID from the server that can't be used in a URL");
}
return getConnectionUrl(connectionHost, secure, namespace, /* optLastSessionId= */ null);
}

/**
* Returns whether a host that the server sent can be used to connect to this database, that is,
* whether the connection URL for it is a valid URI that names a host. This only checks the
* syntax. It doesn't restrict which hosts the server can send.
*/
boolean isUsableHost(String host) {
URI url = tryGetConnectionUrl(host, /* optLastSessionId= */ null);
// Without a host in the URL, the websocket would connect to the loopback address.
return url != null && url.getHost() != null;
}

/** Returns the connection URL, or null if it isn't a valid URI. */
@Nullable
private URI tryGetConnectionUrl(String host, @Nullable String optLastSessionId) {
try {
return getConnectionUrl(host, secure, namespace, optLastSessionId);
} catch (IllegalArgumentException e) {
// URI.create throws if the URL contains characters that aren't allowed in a URI, such as a
// space.
return null;
}
}

public String getHost() {
return this.host;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,7 @@ public WebsocketConnection(

private WSClient createConnection(
HostInfo hostInfo, String optCachedHost, String appCheckToken, String optLastSessionId) {
String host = (optCachedHost != null) ? optCachedHost : hostInfo.getHost();
URI uri =
HostInfo.getConnectionUrl(
host, hostInfo.isSecure(), hostInfo.getNamespace(), optLastSessionId);
URI uri = hostInfo.getConnectionUrl(optCachedHost, optLastSessionId, logger);
Map<String, String> extraHeaders = new HashMap<String, String>();
extraHeaders.put("User-Agent", connectionContext.getUserAgent());
extraHeaders.put("X-Firebase-GMPID", connectionContext.getApplicationId());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.database.connection;

import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.google.firebase.database.MapBuilder;
import com.google.firebase.database.logging.Logger;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedConstruction;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;

/** Tests how a connection handles a reset control message, which tells it to use another host. */
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class ConnectionResetTest {

/** Hosts that the server or the emulator can send. */
private static final List<String> VALID_HOSTS =
Arrays.asList(
"s-usc1b-nss-2073.firebaseio.com",
"x.europe-west1.firebasedatabase.app",
"localhost:9000",
"10.0.2.2:9000",
"[::1]:9000");

/** Hosts that can't be used in a connection URL. */
private static final List<String> INVALID_HOSTS =
Arrays.asList("bad host", "a|b.firebaseio.com", "a^b.firebaseio.com", "");

private final Logger logger = mock(Logger.class);
private MockedConstruction<WebsocketConnection> websockets;
private ConnectionContext context;

@Before
public void setUp() {
// Log everything, so that the debug logging code paths run too.
when(logger.getLogLevel()).thenReturn(Logger.Level.DEBUG);
// Replace the websocket with a mock, so that the tests can check that it is closed. The
// connection is never opened, and server messages are delivered by calling onMessage directly.
websockets = mockConstruction(WebsocketConnection.class);
context =
new ConnectionContext(
logger,
mock(ConnectionTokenProvider.class),
mock(ConnectionTokenProvider.class),
mock(ScheduledExecutorService.class),
/* persistenceEnabled= */ false,
"test-sdk-version",
"test-user-agent",
"test-app-id",
/* sslCacheDirectory= */ null);
}

@After
public void tearDown() {
websockets.close();
}

@Test
public void resetWithValidHost_cachesHostAndReconnectsImmediately() {
for (String host : VALID_HOSTS) {
Connection.Delegate delegate = sendResetToNewConnection(host);

verify(delegate).onCacheHost(host);
verify(delegate).onDisconnect(Connection.DisconnectReason.SERVER_RESET);
verify(lastWebsocket()).close();
}
}

@Test
public void resetWithoutHost_clearsCachedHostAndReconnectsImmediately() {
Connection.Delegate delegate = sendResetToNewConnection(/* host= */ null);

verify(delegate).onCacheHost(null);
verify(delegate).onDisconnect(Connection.DisconnectReason.SERVER_RESET);
verify(lastWebsocket()).close();
}

@Test
public void resetWithHostThatIsNotValidInUrl_keepsCachedHostAndReconnectsWithBackoff() {
for (String host : INVALID_HOSTS) {
Connection.Delegate delegate = sendResetToNewConnection(host);

// The persistent connection keeps the host it had. OTHER, unlike SERVER_RESET, makes it
// reconnect with its usual backoff, so a server that keeps sending this reset can't cause a
// tight reconnect loop.
verify(delegate, never()).onCacheHost(any());
verify(delegate).onDisconnect(Connection.DisconnectReason.OTHER);
verify(lastWebsocket()).close();
}
}

@Test
public void resetWithHostThatIsNotValidInUrl_logsWarningWithoutHost() {
sendResetToNewConnection("bad host");

ArgumentCaptor<String> warnings = ArgumentCaptor.forClass(String.class);
verify(logger, atLeastOnce())
.onLogMessage(eq(Logger.Level.WARN), anyString(), warnings.capture(), anyLong());
for (String warning : warnings.getAllValues()) {
assertThat(warning).doesNotContain("bad host");
}
}

/** Sends a reset with {@code host} to a new connection, and returns the connection's delegate. */
private Connection.Delegate sendResetToNewConnection(String host) {
Connection.Delegate delegate = mock(Connection.Delegate.class);
Connection connection =
new Connection(
context,
new HostInfo("test.firebaseio.com", "test", /* secure= */ true),
/* cachedHost= */ null,
delegate,
/* optLastSessionId= */ null,
"test-app-check-token");
connection.onMessage(resetMessage(host));
return delegate;
}

private WebsocketConnection lastWebsocket() {
List<WebsocketConnection> constructed = websockets.constructed();
return constructed.get(constructed.size() - 1);
}

private static Map<String, Object> resetMessage(String host) {
Map<String, Object> reset = new MapBuilder().put("t", "r").put("d", host).build();
return new MapBuilder().put("t", "c").put("d", reset).build();
}
}
Loading
Loading