Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "pnpm run protocol:check && tsc -b && vite build",
"lint": "eslint .",
"type-check": "tsc -b --noEmit",
"protocol:check": "node ../scripts/check-protocol-version.mjs",
"type-check": "pnpm run protocol:check && tsc -b --noEmit",
"preview": "vite preview",
"test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
Expand Down
2 changes: 1 addition & 1 deletion crates/lobby-broker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub use lobby::{
};
pub use protocol::{
parse_lobby_client_message, DraftLobbyMetadata, LobbyClientMessage, LobbyGame,
LobbyServerMessage, ParsedFrame, ServerMode,
LobbyServerMessage, ParsedFrame, ServerMode, MIN_SUPPORTED_PROTOCOL, PROTOCOL_VERSION,
};
pub use reservation_auth::{
conn_holds_reservation, consume_owned_reservation, release_owned_reservation,
Expand Down
16 changes: 16 additions & 0 deletions crates/lobby-broker/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ use engine::types::format::{FormatConfig, GameFormat};
use engine::types::match_config::MatchConfig;
use serde::{Deserialize, Serialize};

/// Wire-protocol version shared by the native server, client, and Cloudflare
/// lobby Worker. Bump when any `ClientMessage` or `ServerMessage` variant is
/// added, removed, renamed, or has a field type changed. Adding a new optional
/// field with `#[serde(default)]` does not require a bump.
///
/// Note: renaming or removing a variant silently fails at JSON parse time
/// (clients see "Invalid message: unknown variant") rather than at the
/// handshake. When making such changes, plan a deprecation window where
/// both the old and new variants coexist, then bump and remove the old.
pub const PROTOCOL_VERSION: u32 = 8;

/// Minimum protocol version accepted at the hello handshake. The window is
/// "current and previous" by policy, so a release-vs-preview deployment can
/// coexist in the same lobby server during rollout.
pub const MIN_SUPPORTED_PROTOCOL: u32 = PROTOCOL_VERSION.saturating_sub(1);

/// Public-lobby view of a single registered game. Populated by the server,
/// never by clients. Field shape mirrors the pre-extraction
/// `server_core::protocol::LobbyGame` exactly for wire compatibility.
Expand Down
27 changes: 1 addition & 26 deletions crates/server-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,7 @@ use engine::types::player::PlayerId;
use phase_ai::config::AiDifficulty;
use serde::{Deserialize, Serialize};

/// Wire-protocol version. Bump when any `ClientMessage` or `ServerMessage`
/// variant is added, removed, renamed, or has a field type changed. Adding a
/// new optional field with `#[serde(default)]` does not require a bump.
///
/// Note: renaming or removing a variant silently fails at JSON parse time
/// (clients see "Invalid message: unknown variant") rather than at the
/// handshake. When making such changes, plan a deprecation window where
/// both the old and new variants coexist, then bump and remove the old.
pub const PROTOCOL_VERSION: u32 = 8;

/// Minimum protocol version the server will accept at the hello handshake.
/// Clients on `MIN_SUPPORTED_PROTOCOL..=PROTOCOL_VERSION` are admitted to the
/// lobby; older clients are rejected. The window is "current and previous" by
/// policy — each bump deprecates exactly one version behind, so a release-vs-
/// preview deployment can coexist in the same lobby server during the rollout.
///
/// Derived from `PROTOCOL_VERSION` so a bump automatically rolls the floor.
/// Use `saturating_sub` so the constant is well-defined when `PROTOCOL_VERSION`
/// is 0 (range collapses to `0..=0`, no underflow).
///
/// Note: admission to the lobby does not guarantee that every game wire
/// operation is bidirectionally compatible across versions. Per-game cross-
/// version filtering is a follow-up; until it lands, browsing succeeds but a
/// v6 client clicking "join" on a v7-hosted game will fail at the seat-message
/// boundary with an opaque deserialize error.
pub const MIN_SUPPORTED_PROTOCOL: u32 = PROTOCOL_VERSION.saturating_sub(1);
pub use lobby_broker::{MIN_SUPPORTED_PROTOCOL, PROTOCOL_VERSION};

/// Git short-hash of the build. Emitted by `build.rs`; falls back to `"dev"`
/// when git isn't available (containers, source tarballs).
Expand Down
21 changes: 19 additions & 2 deletions lobby-worker/broker-wasm/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 39 additions & 9 deletions lobby-worker/broker-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

use lobby_broker::{
parse_lobby_client_message, Broker, BrokerEnv, ConnState, LobbyClientMessage,
LobbyServerMessage, Outbound, ParsedFrame,
LobbyServerMessage, Outbound, ParsedFrame, PROTOCOL_VERSION,
};
use rand::Rng;
use serde::Serialize;
Expand All @@ -36,13 +36,17 @@ impl BrokerEnv for WorkerEnv {

fn new_token(&self) -> String {
let mut rng = rand::rng();
(0..32).map(|_| format!("{:x}", rng.random_range(0u8..16))).collect()
(0..32)
.map(|_| format!("{:x}", rng.random_range(0u8..16)))
.collect()
}

fn new_game_code(&self) -> String {
let mut rng = rand::rng();
let chars: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".chars().collect();
(0..6).map(|_| chars[rng.random_range(0..chars.len())]).collect()
(0..6)
.map(|_| chars[rng.random_range(0..chars.len())])
.collect()
}
}

Expand Down Expand Up @@ -136,7 +140,9 @@ impl WasmBroker {
/// Fresh empty broker — cold start with no stored snapshot.
#[wasm_bindgen(constructor)]
pub fn new() -> WasmBroker {
WasmBroker { inner: Broker::new() }
WasmBroker {
inner: Broker::new(),
}
}

/// Restore from a DO-storage snapshot. Falls back to an empty broker if the
Expand All @@ -145,7 +151,9 @@ impl WasmBroker {
pub fn from_snapshot(json: &str) -> WasmBroker {
match serde_json::from_str::<Broker>(json) {
Ok(inner) => WasmBroker { inner },
Err(_) => WasmBroker { inner: Broker::new() },
Err(_) => WasmBroker {
inner: Broker::new(),
},
}
}

Expand All @@ -167,7 +175,9 @@ impl WasmBroker {
/// attachment, `now_ms` is JS `Date.now()`. Returns a [`CallResult`] as JSON.
pub fn handle(&mut self, conn_json: &str, raw_frame: &str, now_ms: f64) -> String {
let mut conn: ConnState = serde_json::from_str(conn_json).unwrap_or_default();
let env = WorkerEnv { now_ms: now_ms as u64 };
let env = WorkerEnv {
now_ms: now_ms as u64,
};

let (outbounds, dirty, reject) = match parse_lobby_client_message(raw_frame) {
ParsedFrame::Message(msg) => {
Expand All @@ -189,7 +199,12 @@ impl WasmBroker {
}
};

result_json(CallResult { conn, outbounds: to_dtos(outbounds), dirty, reject })
result_json(CallResult {
conn,
outbounds: to_dtos(outbounds),
dirty,
reject,
})
}

/// Socket-close teardown: release the connection's seat reservations and
Expand All @@ -199,14 +214,21 @@ impl WasmBroker {
let outbounds = self.inner.on_disconnect(&mut conn);
// A close releases reservations / removes a hosted entry — treat as a
// mutation so the shell snapshots (cheap: close is low-frequency).
result_json(CallResult { conn, outbounds: to_dtos(outbounds), dirty: true, reject: None })
result_json(CallResult {
conn,
outbounds: to_dtos(outbounds),
dirty: true,
reject: None,
})
}

/// Staleness reaper, driven by a DO alarm (a hibernated DO has no tokio
/// interval). Returns the ordered `Outbound`s (a `LobbyGameRemoved` per
/// reaped entry) as a JSON array — there is no connection scope here.
pub fn reap_expired(&mut self, timeout_secs: f64, now_ms: f64) -> String {
let env = WorkerEnv { now_ms: now_ms as u64 };
let env = WorkerEnv {
now_ms: now_ms as u64,
};
let outbounds = self.inner.reap_expired(timeout_secs as u64, &env);
serde_json::to_string(&to_dtos(outbounds)).expect("outbounds always serialize")
}
Expand All @@ -218,6 +240,14 @@ impl Default for WasmBroker {
}
}

/// The shared phase.rs wire-protocol version. The Cloudflare Worker shell uses
/// this for `ServerHello` and its pre-broker handshake gate, so it cannot drift
/// from the Rust protocol constant.
#[wasm_bindgen]
pub fn protocol_version() -> u32 {
PROTOCOL_VERSION
}

fn result_json(r: CallResult) -> String {
serde_json::to_string(&r).expect("call result always serializes")
}
13 changes: 6 additions & 7 deletions lobby-worker/src/hello-gate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
// Mirrors phase-server `classify_hello_gate` for the Cloudflare DO shell.

import { PROTOCOL_VERSION } from "./protocol";

export const MIN_SUPPORTED_PROTOCOL = PROTOCOL_VERSION - 1;

export type HelloGateOutcome =
| { kind: "accept" }
| { kind: "reject_handshake" }
Expand All @@ -21,18 +17,21 @@ export interface ConnAttachment {
export function classifyHelloGate(
helloReceived: boolean,
frame: { type?: string; data?: Record<string, unknown> },
serverProtocolVersion: number,
): HelloGateOutcome {
const minSupportedProtocol = Math.max(0, serverProtocolVersion - 1);
if (frame.type === "ClientHello") {
if (!helloReceived) {
const protocolVersion = Number(frame.data?.protocol_version ?? 0);
if (
protocolVersion < MIN_SUPPORTED_PROTOCOL ||
protocolVersion > PROTOCOL_VERSION
Number.isNaN(protocolVersion) ||
protocolVersion < minSupportedProtocol ||
protocolVersion > serverProtocolVersion
) {
Comment on lines 25 to 30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Unsafe NaN handling in protocol version check. Evidence: lobby-worker/src/hello-gate.ts:25-28.
Why it matters: If the client sends a non-numeric or malformed protocol_version, Number(...) returns NaN, which makes all comparison checks evaluate to false and incorrectly accepts the handshake.
Suggested fix: Explicitly check for NaN or default to 0 when parsing fails to ensure malformed versions are rejected.

      const protocolVersion = Number(frame.data?.protocol_version);
      if (
        Number.isNaN(protocolVersion) ||
        protocolVersion < minSupportedProtocol ||
        protocolVersion > serverProtocolVersion
      )

return {
kind: "reject_protocol",
client: protocolVersion,
server: PROTOCOL_VERSION,
server: serverProtocolVersion,
};
}
return { kind: "accept" };
Expand Down
6 changes: 3 additions & 3 deletions lobby-worker/src/lobby-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,20 @@
// logic, the host language is a serialization boundary with zero game logic.

import wasmModule from "./broker-wasm-pkg/broker_bg.wasm";
import { initSync, WasmBroker } from "./broker-wasm-pkg/broker.js";
import { initSync, protocol_version, WasmBroker } from "./broker-wasm-pkg/broker.js";
import {
classifyHelloGate,
helloGateErrorMessage,
type ConnAttachment,
} from "./hello-gate";
import { moderationErrorForLobbyFrame } from "./name-filter";
import { PROTOCOL_VERSION } from "./protocol";

// Instantiate the broker WASM once per isolate, at top level (CF imports `.wasm`
// as a WebAssembly.Module; `initSync` wires the wasm-bindgen imports
// synchronously). Doing this here — not per request — avoids re-instantiation.
initSync({ module: wasmModule });

const PROTOCOL_VERSION = protocol_version();
const SERVER_VERSION = "lobby-rs";
// build_commit is cosmetic for a LobbyOnly broker — the gameplay-relevant gate
// is each room's host_build_commit (enforced inside the Rust core), not the
Expand Down Expand Up @@ -139,7 +139,7 @@ export class LobbyDO {
}

const attachment = conn as ConnAttachment;
const gate = classifyHelloGate(attachment.client_hello != null, frame);
const gate = classifyHelloGate(attachment.client_hello != null, frame, PROTOCOL_VERSION);
const gateError = helloGateErrorMessage(gate);
if (gateError) {
ws.send(JSON.stringify({ type: "Error", data: { message: gateError } }));
Expand Down
53 changes: 0 additions & 53 deletions lobby-worker/src/protocol.ts

This file was deleted.

Loading
Loading