Skip to content

[java-firestore] UserDataConverter narrows an __int__ beyond 32 bits silently, so a cursor built from it names another position #14633

Open

Description

@laughingman7743

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:

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

  1. added
    type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.
    priority: p2Moderately-important priority. Fix may not be included in next release.
    on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

priority: p2Moderately-important priority. Fix may not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions