From 1476ad06f0957816009e08df32edf9ee773b02c5 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 30 Oct 2026 03:15:14 -0700 Subject: [PATCH 1/2] fix(database): stop invalid server hosts and session IDs from crashing The server sends a host to use for later connections in the handshake ("h") and in a reset control message ("d"), and a session ID in the handshake ("s"). The client stored them unchecked and built the next connection URL with URI.create, which throws IllegalArgumentException when a value contains a character that isn't allowed in a URI, such as a space. This happened on the run loop when reconnecting, so the app crashed. After a reset, the reconnect was immediate. Build the connection URL through a new HostInfo helper that checks the cached host and the last session ID separately. A host that can't be used in a URL, or whose URL doesn't name a host, is replaced with the configured host. Without a host, the websocket would have connected to the loopback address. A session ID that can't be used is dropped. Each case logs a warning that doesn't include the value. Checking the values separately matters: if a bad session ID also discarded a good host, the configured host could reset the connection to that host again and again, with no backoff in between. A reset with an unusable host now keeps the current host and closes with DisconnectReason.OTHER instead of SERVER_RESET, so the persistent connection reconnects with its usual backoff instead of immediately. Legitimate hosts, including emulator hosts such as localhost:9000, work as before. --- firebase-database/CHANGELOG.md | 4 + .../database/connection/Connection.java | 9 + .../database/connection/HostInfo.java | 56 ++++++ .../connection/WebsocketConnection.java | 5 +- .../connection/ConnectionResetTest.java | 161 +++++++++++++++ .../database/connection/HostInfoTest.java | 190 ++++++++++++++++++ .../connection/WebsocketConnectionTest.java | 123 ++++++++++++ 7 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 firebase-database/src/test/java/com/google/firebase/database/connection/ConnectionResetTest.java create mode 100644 firebase-database/src/test/java/com/google/firebase/database/connection/HostInfoTest.java create mode 100644 firebase-database/src/test/java/com/google/firebase/database/connection/WebsocketConnectionTest.java diff --git a/firebase-database/CHANGELOG.md b/firebase-database/CHANGELOG.md index 7918e6fa2a1..c438491aefd 100644 --- a/firebase-database/CHANGELOG.md +++ b/firebase-database/CHANGELOG.md @@ -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. + (#PR_NUMBER) + # 23.0.0 - [changed] **Breaking Change**: Updated minSdkVersion to API level 24 or higher. (#8638) diff --git a/firebase-database/src/main/java/com/google/firebase/database/connection/Connection.java b/firebase-database/src/main/java/com/google/firebase/database/connection/Connection.java index 1ec17c5c4b8..7d53ad4492e 100644 --- a/firebase-database/src/main/java/com/google/firebase/database/connection/Connection.java +++ b/firebase-database/src/main/java/com/google/firebase/database/connection/Connection.java @@ -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 " diff --git a/firebase-database/src/main/java/com/google/firebase/database/connection/HostInfo.java b/firebase-database/src/main/java/com/google/firebase/database/connection/HostInfo.java index 156a73508f3..dd65d97fb5b 100644 --- a/firebase-database/src/main/java/com/google/firebase/database/connection/HostInfo.java +++ b/firebase-database/src/main/java/com/google/firebase/database/connection/HostInfo.java @@ -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 { @@ -55,6 +57,60 @@ public static URI getConnectionUrl( return URI.create(url); } + /** + * Returns the URL for a new connection to this database. + * + *

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; } diff --git a/firebase-database/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java b/firebase-database/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java index 5d1223d9968..73026cd5b14 100644 --- a/firebase-database/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java +++ b/firebase-database/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java @@ -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 extraHeaders = new HashMap(); extraHeaders.put("User-Agent", connectionContext.getUserAgent()); extraHeaders.put("X-Firebase-GMPID", connectionContext.getApplicationId()); diff --git a/firebase-database/src/test/java/com/google/firebase/database/connection/ConnectionResetTest.java b/firebase-database/src/test/java/com/google/firebase/database/connection/ConnectionResetTest.java new file mode 100644 index 00000000000..39f9b219cf0 --- /dev/null +++ b/firebase-database/src/test/java/com/google/firebase/database/connection/ConnectionResetTest.java @@ -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 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 INVALID_HOSTS = + Arrays.asList("bad host", "a|b.firebaseio.com", "a^b.firebaseio.com", ""); + + private final Logger logger = mock(Logger.class); + private MockedConstruction 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 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 constructed = websockets.constructed(); + return constructed.get(constructed.size() - 1); + } + + private static Map resetMessage(String host) { + Map reset = new MapBuilder().put("t", "r").put("d", host).build(); + return new MapBuilder().put("t", "c").put("d", reset).build(); + } +} diff --git a/firebase-database/src/test/java/com/google/firebase/database/connection/HostInfoTest.java b/firebase-database/src/test/java/com/google/firebase/database/connection/HostInfoTest.java new file mode 100644 index 00000000000..eae40150d5b --- /dev/null +++ b/firebase-database/src/test/java/com/google/firebase/database/connection/HostInfoTest.java @@ -0,0 +1,190 @@ +// 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 com.google.common.truth.Truth.assertWithMessage; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.logging.Logger; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class HostInfoTest { + + private static final String CONFIGURED_HOST_URL = "wss://test.firebaseio.com/.ws?ns=test&v=5"; + private static final String SERVER_HOST = "s-usc1b-nss-2073.firebaseio.com"; + private static final String SERVER_HOST_URL = + "wss://s-usc1b-nss-2073.firebaseio.com/.ws?ns=test&v=5"; + + /** Hosts that the server or the emulator can send. */ + private static final List 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. With the first ones, the URL isn't a valid URI. + * With the last ones, it is, but it doesn't name a host. + */ + private static final List INVALID_HOSTS = + Arrays.asList( + "bad host", + "a|b.firebaseio.com", + "a^b.firebaseio.com", + "{a}.firebaseio.com", + "a\"b.firebaseio.com", + "a\\b.firebaseio.com", + "a%zz.firebaseio.com", + "", + "test.firebaseio.com:port"); + + /** Session IDs that make the connection URL an invalid URI. */ + private static final List INVALID_SESSION_IDS = + Arrays.asList("a b", "a|b", "a\"b", "{a}", "%"); + + private final Logger logger = mock(Logger.class); + private final LogWrapper logWrapper = new LogWrapper(logger, "HostInfoTest"); + private final HostInfo hostInfo = new HostInfo("test.firebaseio.com", "test", /* secure= */ true); + private final HostInfo emulatorHostInfo = + new HostInfo("10.0.2.2:9000", "test", /* secure= */ false); + + @Test + public void isUsableHost_acceptsValidHosts() { + for (String host : VALID_HOSTS) { + assertWithMessage("host: %s", host).that(hostInfo.isUsableHost(host)).isTrue(); + assertWithMessage("emulator, host: %s", host) + .that(emulatorHostInfo.isUsableHost(host)) + .isTrue(); + } + } + + @Test + public void isUsableHost_rejectsHostsThatCannotBeUsedInUrl() { + for (String host : INVALID_HOSTS) { + assertWithMessage("host: %s", host).that(hostInfo.isUsableHost(host)).isFalse(); + } + } + + @Test + public void getConnectionUrl_withoutHostOrSessionIdFromServer_usesConfiguredHost() { + assertThat(getConnectionUrl(/* cachedHost= */ null, /* lastSessionId= */ null)) + .isEqualTo(CONFIGURED_HOST_URL); + assertNoWarnings(); + } + + @Test + public void getConnectionUrl_withValidHostAndSessionId_usesBoth() { + assertThat(getConnectionUrl(SERVER_HOST, "session-id")) + .isEqualTo(SERVER_HOST_URL + "&ls=session-id"); + assertNoWarnings(); + } + + @Test + public void getConnectionUrl_withValidHosts_usesThem() { + for (String host : VALID_HOSTS) { + assertThat(getConnectionUrl(host, /* lastSessionId= */ null)) + .isEqualTo("wss://" + host + "/.ws?ns=test&v=5"); + } + assertNoWarnings(); + } + + @Test + public void getConnectionUrl_forEmulator_usesInsecureScheme() { + assertThat(emulatorHostInfo.getConnectionUrl(null, null, logWrapper).toString()) + .isEqualTo("ws://10.0.2.2:9000/.ws?ns=test&v=5"); + assertThat( + emulatorHostInfo + .getConnectionUrl("localhost:9000", "session-id", logWrapper) + .toString()) + .isEqualTo("ws://localhost:9000/.ws?ns=test&v=5&ls=session-id"); + assertNoWarnings(); + } + + @Test + public void getConnectionUrl_withInvalidHost_usesConfiguredHost() { + for (String host : INVALID_HOSTS) { + assertWithMessage("host: %s", host) + .that(getConnectionUrl(host, /* lastSessionId= */ null)) + .isEqualTo(CONFIGURED_HOST_URL); + } + } + + @Test + public void getConnectionUrl_withInvalidHost_keepsSessionId() { + assertThat(getConnectionUrl("bad host", "session-id")) + .isEqualTo(CONFIGURED_HOST_URL + "&ls=session-id"); + } + + @Test + public void getConnectionUrl_withInvalidSessionId_omitsSessionId() { + for (String sessionId : INVALID_SESSION_IDS) { + assertWithMessage("session ID: %s", sessionId) + .that(getConnectionUrl(/* cachedHost= */ null, sessionId)) + .isEqualTo(CONFIGURED_HOST_URL); + } + } + + @Test + public void getConnectionUrl_withInvalidSessionId_keepsHost() { + // If this used the configured host instead, and the server there reset the connection to the + // same host again, the client would reconnect in a tight loop. + assertThat(getConnectionUrl(SERVER_HOST, "a b")).isEqualTo(SERVER_HOST_URL); + } + + @Test + public void getConnectionUrl_withInvalidHostAndSessionId_usesConfiguredHostWithoutSessionId() { + assertThat(getConnectionUrl("a|b.firebaseio.com", "a b")).isEqualTo(CONFIGURED_HOST_URL); + } + + @Test + public void getConnectionUrl_withInvalidValues_logsWarningsWithoutTheValues() { + getConnectionUrl("bad host", "bad session"); + + ArgumentCaptor warnings = ArgumentCaptor.forClass(String.class); + verify(logger, times(2)) + .onLogMessage(eq(Logger.Level.WARN), anyString(), warnings.capture(), anyLong()); + for (String warning : warnings.getAllValues()) { + assertThat(warning).doesNotContain("bad host"); + assertThat(warning).doesNotContain("bad session"); + } + } + + private String getConnectionUrl(String cachedHost, String lastSessionId) { + return hostInfo.getConnectionUrl(cachedHost, lastSessionId, logWrapper).toString(); + } + + private void assertNoWarnings() { + verify(logger, never()) + .onLogMessage(eq(Logger.Level.WARN), anyString(), anyString(), anyLong()); + } +} diff --git a/firebase-database/src/test/java/com/google/firebase/database/connection/WebsocketConnectionTest.java b/firebase-database/src/test/java/com/google/firebase/database/connection/WebsocketConnectionTest.java new file mode 100644 index 00000000000..50dab141748 --- /dev/null +++ b/firebase-database/src/test/java/com/google/firebase/database/connection/WebsocketConnectionTest.java @@ -0,0 +1,123 @@ +// 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.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; + +import com.google.firebase.database.logging.Logger; +import com.google.firebase.database.tubesock.WebSocket; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +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.MockedConstruction; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Tests the URL that a new websocket connection uses. The persistent connection creates a new + * websocket connection every time it reconnects, with the host and the session ID that the server + * sent on an earlier connection. + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class WebsocketConnectionTest { + + private static final HostInfo HOST_INFO = + new HostInfo("test.firebaseio.com", "test", /* secure= */ true); + private static final String SERVER_HOST = "s-usc1b-nss-2073.firebaseio.com"; + + private final List urls = new ArrayList<>(); + private MockedConstruction webSockets; + private ConnectionContext context; + + @Before + public void setUp() { + // Record the URL of each websocket instead of creating a real one. The tests never call + // open(), so nothing connects to the network. + webSockets = + mockConstruction( + WebSocket.class, + (webSocket, construction) -> urls.add((URI) construction.arguments().get(1))); + context = + new ConnectionContext( + mock(Logger.class), + 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 withoutHostOrSessionIdFromServer_usesConfiguredHost() { + assertThat(createConnectionUrl(/* cachedHost= */ null, /* lastSessionId= */ null)) + .isEqualTo("wss://test.firebaseio.com/.ws?ns=test&v=5"); + } + + @Test + public void withValidHostAndSessionIdFromServer_usesBoth() { + assertThat(createConnectionUrl(SERVER_HOST, "session-id")) + .isEqualTo("wss://s-usc1b-nss-2073.firebaseio.com/.ws?ns=test&v=5&ls=session-id"); + } + + @Test + public void withHostFromServerThatIsNotValidInUrl_usesConfiguredHost() { + assertThat(createConnectionUrl("bad host", "session-id")) + .isEqualTo("wss://test.firebaseio.com/.ws?ns=test&v=5&ls=session-id"); + } + + @Test + public void withSessionIdFromServerThatIsNotValidInUrl_omitsSessionId() { + assertThat(createConnectionUrl(SERVER_HOST, "a b")) + .isEqualTo("wss://s-usc1b-nss-2073.firebaseio.com/.ws?ns=test&v=5"); + } + + @Test + public void withHostAndSessionIdFromServerThatAreNotValidInUrl_usesConfiguredHost() { + assertThat(createConnectionUrl("a|b.firebaseio.com", "a b")) + .isEqualTo("wss://test.firebaseio.com/.ws?ns=test&v=5"); + } + + /** + * Creates a websocket connection the way a reconnect does, and returns the URL that it would + * connect to. + */ + private String createConnectionUrl(String cachedHost, String lastSessionId) { + new WebsocketConnection( + context, + HOST_INFO, + cachedHost, + "test-app-check-token", + mock(WebsocketConnection.Delegate.class), + lastSessionId); + assertThat(urls).hasSize(1); + return urls.get(0).toString(); + } +} From a2bf46b6d0785fd71610ba96955c009346bbab56 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 30 Oct 2026 07:27:04 -0700 Subject: [PATCH 2/2] docs(database): add PR number to changelog entry --- firebase-database/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-database/CHANGELOG.md b/firebase-database/CHANGELOG.md index c438491aefd..884bd96b5df 100644 --- a/firebase-database/CHANGELOG.md +++ b/firebase-database/CHANGELOG.md @@ -2,7 +2,7 @@ - [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. - (#PR_NUMBER) + (#8780) # 23.0.0