Skip to content

bug(gateway): comma-containing actor key components collide during query routing #5807

Open

Description

@Utkarshpandey0001

Bug: Comma-containing actor key components collide during query routing

Summary

Query-based gateway routing does not preserve actor key component boundaries when a component contains a comma.

Both the TypeScript and Rust clients serialize compound actor keys by joining their components with commas. The guard then URL-decodes the value and splits it on every comma.

Consequently, these two distinct actor keys produce exactly the same gateway routing value:

["tenant,admin"]
["tenant", "admin"]

Both are serialized as:

rvt-key=tenant%2Cadmin

The guard parses both values as:

["tenant", "admin"]

This can route requests to the wrong actor and potentially break tenant isolation when actor key components contain user-controlled values.

Steps to reproduce

Create two handles whose keys differ only in component boundaries:

const first = client.room.getOrCreate(["tenant,admin"]);
const second = client.room.getOrCreate(["tenant", "admin"]);

console.log(await first.getGatewayUrl());
console.log(await second.getGatewayUrl());

The resulting URLs contain the same routing parameter:

rvt-key=tenant%2Cadmin

Requests sent through both handles can therefore resolve to the same actor.

The same collision occurs between a string key and a compound key:

client.room.getOrCreate("tenant,admin");
client.room.getOrCreate(["tenant", "admin"]);

The string is internally normalized to ["tenant,admin"], but query-based routing changes it into the two-component key ["tenant", "admin"].

A minimal demonstration of the serialization collision is:

const keys = [
  ["tenant,admin"],
  ["tenant", "admin"],
];

for (const key of keys) {
  const params = new URLSearchParams();
  params.append("rvt-key", key.join(","));
  console.log(JSON.stringify(key), params.toString());
}

Output:

["tenant,admin"] rvt-key=tenant%2Cadmin
["tenant","admin"] rvt-key=tenant%2Cadmin

Expected behavior

Actor key serialization must preserve component boundaries.

These keys must identify different actors:

["tenant,admin"] !== ["tenant", "admin"]

Every supported actor key array should have a unique gateway representation.

Actual behavior

Both keys serialize to the same gateway query parameter and are parsed by the guard as ["tenant", "admin"].

Root cause

The TypeScript client joins key components using commas:

params.append("rvt-key", key.join(","));

Relevant code:

https://github.com/rivet-dev/rivet/blob/main/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts#L291-L294

The Rust client uses the same representation:

push_query_param(&mut params, "rvt-key", &key.join(","));

Relevant code:

https://github.com/rivet-dev/rivet/blob/main/rivetkit-rust/packages/client/src/remote_manager.rs#L610-L613

The guard URL-decodes the parameter and splits it on every comma:

fn split_key(raw: Option<&str>) -> Vec<String> {
    match raw {
        None | Some("") => Vec::new(),
        Some(s) => s.split(',').map(String::from).collect(),
    }
}

Relevant code:

https://github.com/rivet-dev/rivet/blob/main/engine/packages/guard/src/routing/actor_path.rs#L271-L277

Percent-encoding does not prevent the collision because the guard decodes %2C back to , before calling split_key.

Impact

This bug can:

  • Route requests to the wrong actor.
  • Cause two logically distinct keys to share actor state.
  • Break access to actors created with comma-containing key components.
  • Create inconsistent behavior between direct actor creation and query-based get or getOrCreate operations.
  • Violate tenant or resource isolation when user-controlled identifiers contain commas.
  • Undermine the documented recommendation that array keys safely preserve user-provided components and prevent key injection.

The relevant documentation states that array keys make user-provided key data safe:

https://github.com/rivet-dev/rivet/blob/main/docs/content/docs/keys.mdx#L13-L22

Affected implementations

  • TypeScript RivetKit client
  • Rust RivetKit client
  • Guard query-based actor routing
  • HTTP requests, actions, queue operations, and WebSocket connections that use query-backed get or getOrCreate handles

Suggested fix

Replace comma-separated key serialization with an unambiguous representation shared by the TypeScript client, Rust client, and guard.

Possible approaches include:

  • A versioned CBOR/base64url-encoded key array.
  • Repeated rvt-key parameters, with the guard treating each occurrence as one component.
  • The existing canonical actor-key serializer, which already escapes component separators.

Backward compatibility for existing comma-delimited gateway URLs may need to be considered.

Suggested regression tests

Add cross-client and guard tests proving that the following pairs remain distinct:

["a,b"]       // must differ from ["a", "b"]
[""]          // must differ from []
["a,", "b"]   // must differ from ["a", "", "b"]
[","]         // must differ from ["", ""]

Tests should cover both get and getOrCreate, including HTTP and WebSocket gateway URLs.

Environment

Verified against local main checkout:

Commit: 78336a1a0ee33bb45e6b15e89963cbe91713353b
RivetKit package version: 2.3.17

Activity

  1. linear-code commented on Sep 29, 2026

    @linear-code
  2. Abhikansh3 commented on Sep 29, 2026

    @Abhikansh3

    Opened a fix for this: #5808. Actor key components are now sent as repeated rvt-key-part query params instead of being comma-joined, so a component containing a literal comma can no longer collide with a multi-component key (["tenant,admin"] vs ["tenant","admin"]). The legacy rvt-key param is kept unchanged for older/pinned clients.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions