Repository navigation
[java-firestore] UserDataConverter narrows an __int__ beyond 32 bits silently, so a cursor built from it names another position #14633
Description
Issue Details
UserDataConverter.decodeInt32Value casts the __int__ field's 64-bit integer_value to int without a range check, so a value outside the 32-bit range decodes to a different Int32Value without any error:
Lines 430 to 436 in 9de611f
| static Int32Value decodeInt32Value(MapValue mapValue) { | |
| // The "integer_value" in the proto is a 64-bit integer, but since this | |
| // value is in a special map with the "__int__" field, we know we can | |
| // safely cast this value down to a 32-bit integer value. | |
| long value = mapValue.getFieldsMap().get(MapType.RESERVED_INT32_KEY).getIntegerValue(); | |
| return new Int32Value((int) value); | |
| } |
The comment there says the cast is safe because the value sits in an __int__ map. Nothing on the decode path enforces that. Because a query cursor built from a snapshot (startAfter(DocumentSnapshot), startAt, endBefore, âĶ) decodes the ordered field and re-encodes it, the cursor then names a different position, and a paginated read can skip or repeat documents.
Reachability. Writing these forms through the Firestore API is refused with INVALID_ARGUMENT by the emulator (587.0.0-emulators) and by both a Standard-edition and an Enterprise-edition database, so a document written through that API should never carry them (measured 2026-10-03). Writes through the Enterprise edition's MongoDB-compatible API were not tried. Even if the input is unreachable today, the decoder assumes something it does not check, and the consequence is silent wrong data, not an error. Rejecting an out-of-range value, for example with Math.toIntExact or a FirestoreException, would make it loud.
Found while building an Apache Flink source on google-cloud-firestore (flink-gcp/flink-connector-gcp).
Environment
- OS Type and Version: macOS 26 (arm64)
- Java Version and JDK Vendor: Eclipse Temurin 17.0.20
The reproducer is offline: it builds the snapshot from a Document proto with com.google.cloud.firestore.Internal, the way the client does for a server response, and sends nothing.
Dependencies
- Libraries-Bom: 26.90.0
- google-cloud-firestore 3.49.0
- Gax: 2.87.0
- Auth: 1.54.0
The affected code is identical in 3.49.0 and on main.
Reproducer
package repro;
import com.google.cloud.NoCredentials;
import com.google.cloud.Timestamp;
import com.google.cloud.firestore.DocumentSnapshot;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.FirestoreOptions;
import com.google.cloud.firestore.Internal;
import com.google.cloud.firestore.Query;
import com.google.cloud.firestore.spi.v1.GrpcFirestoreRpc;
import com.google.firestore.v1.Document;
import com.google.firestore.v1.MapValue;
import com.google.firestore.v1.Value;
import com.google.protobuf.ByteString;
/** Offline: builds snapshots from protos as the client would receive them; nothing is sent. */
public class DecodeDefects {
public static void main(String[] args) throws Exception {
FirestoreOptions options =
FirestoreOptions.newBuilder()
.setProjectId("demo")
.setCredentials(NoCredentials.getInstance())
.build();
Firestore db = options.getService();
// 1. An __int__ beyond 32 bits is narrowed silently.
long stored = (1L << 40) + 5;
DocumentSnapshot wide = snapshot(options, reserved("__int__", Value.newBuilder().setIntegerValue(stored).build()));
System.out.println("stored __int__: " + stored);
System.out.println("decoded value: " + wide.get("k"));
Query query = db.collection("c").orderBy("k").startAfter(wide);
System.out.println("cursor value: " + query.toProto().getStructuredQuery().getStartAt().getValues(0).getMapValue());
// 2. An empty __binary__ throws instead of failing with a decode error.
DocumentSnapshot empty = snapshot(options, reserved("__binary__", Value.newBuilder().setBytesValue(ByteString.EMPTY).build()));
try {
System.out.println("decoded value: " + empty.get("k"));
} catch (RuntimeException e) {
System.out.println("empty __binary__ threw: " + e);
}
System.exit(0);
}
static Value reserved(String key, Value value) {
return Value.newBuilder().setMapValue(MapValue.newBuilder().putFields(key, value)).build();
}
static DocumentSnapshot snapshot(FirestoreOptions options, Value value) throws Exception {
Timestamp now = Timestamp.now();
Document document =
Document.newBuilder()
.setName("projects/demo/databases/(default)/documents/c/x")
.setCreateTime(now.toProto())
.setUpdateTime(now.toProto())
.putFields("k", value)
.build();
try (GrpcFirestoreRpc rpc = new GrpcFirestoreRpc(options)) {
return new Internal(options, rpc).snapshotFromProto(now, document);
}
}
}Only the first case concerns this issue; the second is reported in #14634.
Logs and Stack Trace
stored __int__: 1099511627781
decoded value: Int32Value{value=5}
cursor value: fields {
key: "__int__"
value {
integer_value: 5
}
}
empty __binary__ threw: java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
Behavior
Deterministic: any __int__ whose value is outside [Integer.MIN_VALUE, Integer.MAX_VALUE].
Activity