You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The participant turn-result parser and the relay-log reader are now hardened against non-finite and
double-overflowing numbers. turn_result_from_payload reads a participant's cost_usd, token counts,
and rate-limit signal off an untrusted bus payload with a bare json.loads (which accepts the
non-standard Infinity/NaN tokens), and its _as_int passed a non-finite float straight to int()
(which raises); a 400-digit integer also overflowed float(). The three coercers now default a
non-numeric, non-finite, or overflowing value to zero (or None for the optional signal) so a malformed
turn result cannot crash the bus handler awaiting it. relay.decode_lite's existing tolerance of a
malformed log entry (a non-numeric t/i defaulted to zero) now also covers a non-finite value, whose int() conversion raised OverflowError outside the caught set. Part of the non-finite-number family.
A claim's ttl_seconds and a frame's epoch/expected_version are now guarded against non-finite and
double-overflowing values. The claim handler converted ttl_seconds with a float() that caught only TypeError/ValueError, so a 400-digit integer raised an uncaught OverflowError out of the frame
handler (dropping the socket), and a 1e400 (or "inf") became an inf lease expiry — a task claimed
with an inf ttl could never be taken over (a permanent lock), while a nan ttl read as instantly
expired. SynapseHub._optional_int, which reads epoch/expected_version on the claim/renew/release/
checkpoint frames, passed a non-finite float straight to int(), which raises. Both now treat a
non-numeric, non-finite, or overflowing value as absent: the ttl falls back to the hub's default lease
duration and the optional int to None. Found by live fault-injection of the claim path; part of the
non-finite-number family below.
A chat frame's client-supplied timestamp is now coerced to the hub clock when it is not a usable
instant, instead of crashing the handler or broadcasting a non-finite time. The handler stamped the
message with a bare float(data.get("timestamp") or time.time()), so a non-numeric timestamp (a string
or a list) raised ValueError/TypeError, and a double-overflowing integer raised OverflowError —
none caught by the connection loop (which handles only ConnectionClosed), so a single hostile chat
dropped the sender's socket with a traceback. A finite-looking 1e400 (or a "timestamp": "inf")
instead decoded to inf and was retained in history, journalled, broadcast to every socket, and used as
the dead-letter ledger's ordering key. The timestamp is advisory client metadata, so a missing, falsy,
non-numeric, non-finite, or overflowing value now falls back to the hub's authoritative time.time();
a finite client timestamp is still preserved. Found by fault-injection of the chat handler (the hottest
untrusted path); part of the non-finite-number family below.
The federation gate now stays deny-closed when a peer certificate reads but does not parse. The gate
already wraps the certificate read so a socket in a strange state (or an injected certificate source)
cannot crash the frame handler, and refuses a peered key's cross-domain claim it cannot pin. Computing
the pin was outside that guard, so a certificate that read as non-empty bytes but did not parse would
have raised out of the handler. The pin computation now shares the guard: an unparsable certificate is
treated exactly like a failed read — deny-closed for a peered key, degrade-to-local for a local key —
never a crash. Defence in depth: the production certificate source returns the TLS-validated peer DER,
so this is not reachable on a live mutual-TLS connection, but it completes the gate's "any certificate
failure fails closed" invariant. Found by fault-injection of the federation trust gate.
The federation-bundle and multi-hub numeric guards also reject a JSON integer too large for a double.
The NaN/Infinity guards added above convert with float() and check math.isfinite, but a
400-digit integer is finite JSON that passes the decoder yet raises OverflowError on the float()
conversion (and on math.isfinite of the raw int). A peer bundle's expires_at or a peer event's ts
set to such an integer therefore still escaped as an unhandled OverflowError; both guards now catch it
and raise their own malformed-input error (FederationStoreError / MultiHubWireError). Completes the
non-finite-number family below — the finding coercion helper already caught this case.
The bounded frame decoder now rejects the non-standard NaN/Infinity/-Infinity JSON tokens. RFC
8259 has no such literals, but json.loads accepts them by default, so a non-finite float could enter
through any inbound frame or peer response and then break an ordering comparison (nan compares
unequal to everything) or overflow an int()/float() conversion downstream. loads_bounded — the
single depth-bounded loader every inbound frame, peer sync body, and federation reply passes through —
now raises json.JSONDecodeError for a non-finite constant, so every consumer's existing malformed-JSON
handling fails it closed. This is a defence in depth beneath the per-field guards on the federation
bundle, the multi-hub event ts, and the finding numbers (below); the hub never emits a non-finite
float, so no legitimate frame is affected.
A finding with a non-finite number no longer crashes the finding handler. A finding envelope is
decoded from an untrusted frame and json.loads yields inf/nan from the Infinity/NaN tokens,
but the tolerant coercion helpers converted a numeric field with a bare int()/float(): int(inf) raises OverflowError, int(nan) raises ValueError, and float() of a JSON integer too
large for a double raises OverflowError. A single frame carrying Infinity in provenance.source_event_seq (or nan in a confidence or validity bound) therefore raised an
unhandled exception out of the handler, dropping the sender's connection with a traceback. The helpers
now treat a non-finite or double-overflowing value as no usable number (None) — the same signal they
already return for a non-numeric value — so a hostile finding is rejected cleanly and a nan can no
longer corrupt finding ranking or validity-window checks. Found by fault-injection of the finding
decode path.
A non-finite timestamp in a peer hub's event no longer breaks the deterministic multi-hub merge. The
cross-host event codec converted a stored event's ts with a type check that accepted any float, but json.loads parses the NaN/Infinity tokens, so a peer's wire body could carry a non-finite ts.
A nan compares unequal to everything, so the total-order merge key (ts, hub_id, seq) stopped being
a total order: two hubs folding the same events in different receive orders could sort them
differently and diverge. The codec now rejects a non-finite ts as a malformed body
(MultiHubWireError), the same contract it already applies to every other bad field. Found by
fault-injection of the wire codec.
A malformed federation peer bundle no longer crashes the import or the hub. A numeric field in an
out-of-band bundle — a peer's expires_at or a record's provenance.imported_at — was converted
with a bare float(), so a hostile or corrupt value (a string, a mapping, a list, or a non-finite nan/inf) raised a raw TypeError/ValueError. Every caller catches only FederationStoreError
(a ValueError subclass, which never matched the TypeError cases), so such a bundle escaped as an
unhandled traceback — crashing synapse federation import on a peer's bundle and synapse hub --federation-store at startup on a corrupt store. Both numeric fields now parse through a guarded
conversion that raises FederationStoreError naming the field, and rejects nan/inf (a nan
expiry would defeat the now >= expires_at check and leave a peering that never expires). Found by
fault-injection of the federation bundle parser.
The hub no longer floods its log with full ERROR tracebacks for benign aborted handshakes. A
load-balancer TCP health check, a port scan, or a client that drops before completing the WebSocket
handshake previously logged opening handshake failed with a full traceback each time — on a
production hub, frequent, benign, and enough noise to bury real errors and grow the log without bound.
A HandshakeAbortFilter on the log handler now drops exactly those records (a handshake failure whose
cause chain is a plain connection abort — EOFError/ConnectionError/TimeoutError, matched through
websockets' wrapping exception) while keeping every other log, including a genuine handshake error from
a completed-but-invalid request. Found by live fault-injection testing of the hub.
Added
An API and wire stability policy (docs/api-stability.md): what counts as a stable surface, the
test that guards each one against accidental change (the public __all__, the complete wire MessageType vocabulary, the federation primitives out-of-tree consumers import, and the tiered CLI),
the decoupled wire-protocol version, the stability tiers, and the deprecation policy. The wire message
vocabulary is now frozen in full by tests/test_wire_surface_freeze.py — previously only the count
was pinned, blind to a rename that keeps the count constant.
The hub advertises a wire-protocol version. WIRE_PROTOCOL_VERSION (an integer, baseline 1,
decoupled from the package version so it changes only on a wire-incompatible change) now rides in the welcome handshake as protocol_version and in /health, and a client captures the peer's version
as hub_protocol_version on connect. This gives a consumer that syncs across possibly version-skewed
hubs a compatibility signal to read on connect instead of inferring from a separate query. It is
advertise-only: a client records the peer's version but no compatibility policy is enforced yet
(the mismatch behaviour is a contract to be agreed with the wire's downstream consumers first). A hub
or client that predates the field reads it as absent, so the addition is backward-compatible.
synapse approvals makes the two-person relay quorum operable. The approval ledger is per-hub live
state that enforced a second operator but exposed no way to see which relays were pending, so the
quorum was invisible between the first request and the second approval. The pending set now rides in
the hub's state snapshot (the same one the dashboard and cockpit read) as pending_relay_approvals,
and the new read-only synapse approvals query prints it — oldest first, naming each pending action,
its namespace and task, and the first requester a second, different operator must join to reach
quorum. It holds only what the ledger holds (never a message body).