Repository navigation
Conversation
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.
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. |
📝 PRs merging into main branchOur main branch should always be in a releasable state. If you are working on a larger change, or if you don't want this change to see the light of the day just yet, consider using a feature branch first, and only merge into the main branch when the code complete and ready to be released. |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request fixes a crash in the Realtime Database SDK when the server sends an invalid host or session ID. The SDK now validates these values before constructing connection URLs. Specifically, HostInfo checks if the host or session ID is usable, falling back to the configured host or omitting the invalid session ID while logging a warning. Connection also ignores invalid hosts during a reset and closes the connection with a reason of OTHER to prevent a tight reconnect loop. Comprehensive unit tests have been added to verify these changes. There are no review comments, and I have no feedback to provide.
Summary
Realtime Database no longer crashes the app when the server sends a host (in
the handshake or in a reset message) or a session ID that can't be used in a
URL. The SDK ignores the bad value and keeps connecting.
Problem
The server sends a host to use for later connections in the handshake (
h) andin a reset control message (
d), and a session ID in the handshake (s).PersistentConnectionImplstores them in memory (cachedHost,lastSessionId) without checking them. On the next connection,WebsocketConnection.createConnectionbuilds the URL withURI.create, whichthrows
IllegalArgumentExceptionif a value contains a character that isn'tallowed in a URI (space,
|,^,{,", backslash, and so on). This runson the database run loop as part of the reconnect, and Android rethrows
uncaught run loop exceptions on the main thread, so the app crashes. After a
reset, the crash is immediate, because
SERVER_RESETmakes the persistentconnection reconnect without delay.
Nothing is persisted, but the crash recurs in every session while the server
keeps sending the value. A related case doesn't crash: a host whose URL is a
valid URI with no host in it (for example
""). The websocket then connectsto the loopback address (
new Socket(null, port)), so the client can'treconnect for the rest of the session.
The trigger needs a backend bug, regression, or protocol change. This comes
from an expanded audit of how the SDK handles unexpected server data, which
followed
firebase/firebase-ios-sdk#16728.
Fix
HostInfo.isUsableHost(host)is the one shared check: the connection URLfor the host must be a valid URI that names a host. It only checks syntax
and isn't an allow-list. Production hosts
(
s-usc1b-nss-2073.firebaseio.com,x.europe-west1.firebasedatabase.app)and emulator hosts (
localhost:9000,10.0.2.2:9000,[::1]:9000) areaccepted.
WebsocketConnectionbuilds its URL with the newHostInfo.getConnectionUrl(cachedHost, lastSessionId, logger). An unusablecached host is replaced with the configured host, and an unusable session ID
is dropped. Each case logs a warning that doesn't include the value.
also discarded a good cached host. The client would connect to the
configured host. If that host reset the connection to the cached host (as
during a namespace move),
SERVER_RESETwould reset the backoff, the nextURL would fail the same way, and the client would go back to the configured
host. That repeats with no backoff.
Connection.onResetno longer caches a non-null host that isn't usable. Itcloses with
DisconnectReason.OTHERinstead ofSERVER_RESET, so thepersistent connection reconnects with its usual backoff. Otherwise a server
that resets every connection with the same bad host would cause a tight
reconnect loop. A null host behaves as before.
onMessage,onControlMessage,onHandshake). A trial merge with fix(database): handle malformed server messages without crashing #8764 conflicts onlyin
CHANGELOG.md, and the connection tests of both PRs pass together.Alternatives considered:
new Connection(...)inPersistentConnectionImpl: the bad value would stay cached, so everyreconnect would fail for the rest of the session.
onHandshakeis being rewritten infix(database): handle malformed server messages without crashing #8764. Checking when the URL is built covers every path with one check.
work today (for example ones that contain
+or%).*.firebaseio.com): this would break theemulator and other setups.
is bad: this causes the loop described above.
Behavior changes to review:
backoff.
host. An unusable session ID makes reconnects omit the
lsparameter."") is ignored insteadof making the websocket connect to the loopback address.
Testing
New Robolectric tests in
firebase-database/src/test/java/com/google/firebase/database/connection/:HostInfoTest(12 tests): accepts the production and emulator hosts above.Rejects hosts with characters that aren't allowed in a URI, an empty host,
and a non-numeric port. Covers URL selection for each combination of valid
and invalid host and session ID, and the emulator's
ws://scheme. Checksthat warnings don't include the values.
WebsocketConnectionTest(5 tests): creates aWebsocketConnectionthe waya reconnect does and records the URL passed to
WebSocketwithMockito.mockConstruction. With an invalid host or session ID, theconstructor no longer throws, and valid values are used as before.
ConnectionResetTest(4 tests): legitimate hosts and a null host still callonCacheHostand close withSERVER_RESET. Invalid hosts aren't cached,close with
OTHER, and log a warning that doesn't include the host.I wrote the tests first and ran them against the unmodified main source. 5 of
9 failed: 3 of 5 in
WebsocketConnectionTest, with theIllegalArgumentExceptionfromURI.create(the crash), and 2 of 4 inConnectionResetTest.HostInfoTestuses the new helper, so it can't compilewithout the fix.
Commands, all with
--max-workers=4:./gradlew :firebase-database:testDebugUnitTest --tests "com.google.firebase.database.connection.WebsocketConnectionTest" --tests "com.google.firebase.database.connection.ConnectionResetTest":9 tests, 5 failed.
./gradlew :firebase-database:spotlessApply./gradlew :firebase-database:testDebugUnitTest --tests "com.google.firebase.database.connection.*":21 tests, 0 failures.
./gradlew :firebase-database:testDebugUnitTest: 424 tests, 0 failures, 2skipped.
./gradlew :firebase-database:testDebugUnitTest --tests "com.google.firebase.database.connection.*":61 tests, 0 failures.
Not tested on a device or emulator.