fix(gateway): harden the WaveKV sync path (extracted from #1031, no protocol change) - #1036
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens the existing WaveKV v1 HTTP sync path in dstack-gateway without changing protocol, wire format, or dependencies, focusing on memory-safety limits, startup correctness, and adding coverage for previously untested sync/auth/TLS paths.
Changes:
- Add bounded gzip decompression (128 MiB decompressed ceiling) and bound peer response bodies to 16 MiB compressed.
- Fix startup ordering so this node’s critical KV records are published after sync bootstrap rebuilds the sequence counter.
- Add extensive tests covering sync route behavior, auth gate behavior, TLS handshake identity checks, and key-namespace properties; plus a small clippy-driven test fix.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| dstack/gateway/src/web_routes/wavekv_sync.rs | Uses bounded decompression for inbound sync messages, splits authorization decision for testability, and adds route/auth coverage tests. |
| dstack/gateway/src/pp.rs | Updates test helper to repeat_n to satisfy clippy and keep intent clear. |
| dstack/gateway/src/main_service.rs | Moves publishing of this node’s KV records to after bootstrap; passes UUID explicitly into sync service creation. |
| dstack/gateway/src/kv/sync_service.rs | Stops reading “my uuid” from replicated state; takes it from local configuration to avoid ordering pitfalls. |
| dstack/gateway/src/kv/mod.rs | Introduces compressed/decompressed sync size ceilings and a bounded gzip helper with dedicated tests; adds key-namespace tests. |
| dstack/gateway/src/kv/https_client.rs | Adds bounded response-body reads and bounded decompression for peer sync responses; adds TLS transport/identity/status coverage tests. |
Suppressed comments (2)
dstack/gateway/src/main_service.rs:278
- Log/error messages should start with lowercase text (per CLAUDE.md). This message starts with "Failed".
if let Err(err) = kv_store.set_node_status(config.sync.node_id, NodeStatus::Up) {
error!("Failed to set node status: {err:?}");
}
dstack/gateway/src/main_service.rs:282
- Log/error messages should start with lowercase text (per CLAUDE.md). This message starts with "Failed".
if let Err(err) = kv_store.register_peer_url(config.sync.node_id, &config.sync.my_url) {
error!("Failed to register peer URL: {err:?}");
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+399
to
+402
| flate2::read::GzDecoder::new(data) | ||
| .take(limit as u64 + 1) | ||
| .read_to_end(&mut out) | ||
| .context("failed to decompress payload")?; |
Comment on lines
+272
to
+274
| if let Err(err) = kv_store.sync_node(config.sync.node_id, &node_data) { | ||
| error!("Failed to sync this node to KvStore: {err:?}"); | ||
| } |
The sync wire is gzipped and the 16 MiB cap on the request body caps the *compressed* size, which bounds nothing on its own — gzip expands by three orders of magnitude on attacker-chosen input, so that cap admits a payload that expands into the gigabytes and OOM-kills the gateway. Every gateway in a cluster shares one app_id, so the RA-TLS check on the route proves only that the sender is some gateway of this deployment. The client side was worse: peer responses were read with `Body::collect`, which has no limit at all, so the memory was already spent before any decoding bound could apply. Both decompression points now go through one bounded helper (128 MiB ceiling, far above the whole live state a sync response carries), and response bodies go through `Limited` with the same 16 MiB the route accepts on a request. Refs #1029
A local write allocates a sequence number. After a data-directory loss the node keeps its id but has no record of which numbers it already spent — only its peers do — so `bootstrap` rebuilds the counter from their coverage. Anything written before that reuses numbers the peers already treat as seen, and peers filter those writes out of every sync with no error on either side. The three records written at startup were exactly the ones that must not be dropped: `node/info` carries the fresh uuid peers check us against, and `__peer_addr` carries the address they route to. A rebuilt gateway therefore wedged in both directions and stayed wedged. They could not simply be moved, because `HttpSyncNetwork::new` read this node's uuid back out of the store, making the `node/info` write a prerequisite of building the sync service at all. That read is the actual defect: our own uuid is local configuration, not replicated state. It is now passed in, and all three writes happen after the bootstrap. Refs #1029
The HTTP layer was the one part of the sync path with no coverage. It was skipped on the grounds that constructing a `WaveKvSyncService` needs real TLS material; that was wrong. `rcgen` is already a dependency and already used by the cert_store tests, and `verify_gateway_peer` short-circuits under `insecure_skip_attestation`, so a self-signed CA plus a leaf written to a TempDir is enough to build a serving gateway. What this pins that nothing else did: the store dispatch (both arms), 503 — not 404 — when sync is disabled, 404 for an unknown store, the node-id-zero guard, a round trip that actually returns the state this node holds, and the decompression bound at the route rather than at the helper. Refs #1029
Mutation testing found `verify_gateway_peer` replaceable with `Ok(())` without turning the suite red. The sync route is the cluster's write surface — anything reaching it inserts entries that replicate to every gateway — and that function is the only thing in front of it. The cause was in the fixture: every route test sets `insecure_skip_attestation`, which is the function's first statement, so no test had ever executed a line of the gate. Two gaps, so two changes. `enforcing_gateway` runs with the bypass off; Rocket's local client speaks no TLS and so presents no certificate, which is exactly the case that must be refused. And the app-id comparison needed a certificate, which `rocket::mtls::Certificate` cannot produce outside a real handshake — but the adapter over it only ever used `cert.extensions()`, so `RocketCert` now holds the extension list and the authorization rule is split out from the Rocket plumbing it was tangled with. Four cases now pinned: matching id accepted, foreign id forbidden, a certificate without an app id refused, and a gateway with no app id of its own authorizing nobody. Refs #1029
…unds `AppIdValidator` runs inside the TLS handshake, so a validator that always returns `Ok(())` lets this gateway complete a mutually-authenticated connection to any peer holding a certificate our CA signed — and then send it our state. Replacing its body with `Ok(())` left the suite green, and so did deleting the status check on a sync response and on a bootnode fetch: nothing exercised these paths, because `https_only()` means a plain HTTP stub will not do. A local TLS listener with a certificate minted in process covers all four: the identity check over a real handshake, the two status checks, and the bound on a peer's response body. Refs #1029
Every key builder and parser survived mutation: `handshake_prefix` could
return `""`, `parse_inst_key` could return `Some("xyzzy")`, and nothing
noticed. These strings are what a gateway uses to find its own state after an
upgrade — changing one silently orphans every existing record, still
replicated and no longer reachable by any reader.
Four properties pinned: a prefix matches the keys it iterates, a prefix does
not capture a neighbour (`inst-a` must not swallow `inst-ab`), builders and
parsers round-trip, and a parser refuses a key from another namespace.
Also replaces a `repeat().take()` that newer clippy flags in the PROXY-protocol
tests.
kvinwang
force-pushed
the
fix/gateway-sync-hardening
branch
from
August 11, 2026 04:49
15e78e9 to
1a3e699
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts from #1031 everything that does not depend on wavekv 2.0, so it can land while the dual-stack upgrade is still being compatibility-tested. Builds against
wavekv = "1.0.0"as onnext— no dependency bump, no wire-format change, no proto change.Why separately
#1031 is a protocol change: it bumps wavekv to the delta-state branch, adds
/wavekv/sync2+/wavekv/push, and negotiates per peer. That needs a mixed-version test matrix before it can merge. But six of its changes are ordinary robustness work on the existing v1 path, and they were only in that branch because that is where they were written.769f66b5e128d727335fab6f,6d0d20b1authorize_peersplit)8d04ff2237f94318,9a6e2174,8de5d5b1repeat_nclippy fix37f94318,8de509c4Left in #1031 because they need wavekv 2.0: the
/wavekv/sync2and/wavekv/pushroutes, protocol probe-and-fallback,state_digest/PeerLinkStatusreporting inWaveKvStatus(new proto fields), and the key-schema admission policy (it is wired throughwavekv::AdmissionPolicy, which 1.0 does not have).The wavekv-side hardening that is likewise independent of the protocol — tolerant WAL tail recovery and length-bound checks — is going to wavekv as its own PR against
main, so it can ship as a 1.x patch.Problem → Fix
1. Unbounded decompression (#1029 P0.1). The sync wire is gzipped and
data.open(16.mebibytes())caps the compressed size. gzip expands ~1000:1 on attacker-chosen input, so that cap admits a payload expanding into the gigabytes; every gateway in a cluster shares one app_id, so the RA-TLS gate proves only that the sender is some gateway of this deployment. On the client side it was worse — peer responses were read withBody::collect, which has no limit at all, so the memory was already spent before any decoding bound could apply.Both points now go through one bounded helper (128 MiB decompressed ceiling), and response bodies go through
Limitedat the same 16 MiB the route accepts on a request.2. A rebuilt gateway wedged itself (#1029, adjacent to P0.5). A local write allocates a sequence number. After losing its store the node keeps its id but has no record of which numbers it spent — only its peers do — so
SyncManager::bootstrap()rebuilds the counter from their coverage (this is wavekv 1.0 behavior,sync.rs:90). Anything written before that reuses numbers the peers already treat as seen, and it drops out of log dissemination with no error on either side.The three records written at startup were exactly the ones that must not be dropped:
node/infocarries the uuid peers check us against,__peer_addrcarries the address they route to. They could not simply be moved, becauseHttpSyncNetwork::newread this node's uuid back out of the store — our own uuid is local configuration, not replicated state, and routing it through the store created the ordering constraint. It is passed in now, and all three writes happen after the bootstrap.Scope: this completes the recovery when the node's uuid survives the loss — a corrupt or discarded WAL with
{data_dir}/node_uuidpreserved, which is the state the P0.5 quarantine flow should produce. If the uuid file is lost too, it is regenerated, and wavekv's peer-side uuid check (sync.rs:188) then refuses every sync from this node before applying anything — bootstrap included — so the counter is never recovered and the fresh uuid has no channel to propagate through. That gate cannot be fixed from this side of the wire; recovery must preservenode_uuid(the quarantine flow in #1029 P0.5 should exclude it from the quarantined path), tracked with #1031.3. The HTTP layer and the auth gate had no coverage. Mutation testing found
verify_gateway_peerreplaceable withOk(())without turning the suite red — every route test setsinsecure_skip_attestation, which is that function's first statement, so no test had ever executed a line of the gate.AppIdValidator(the client-side mirror, running inside the TLS handshake) had no tests at all. Neither did the status checks on a sync response or a bootnode fetch, becausehttps_only()means a plain HTTP stub will not reach them.None of this needs a TEE or a simulator:
rcgenis already a dependency,CertRequestwritesPHALA_RATLS_APP_IDunconditionally, and the checks read two X.509 extensions and compare bytes.RocketCertnow holds the extension list instead of theCertificate(which has no public constructor outside a real handshake), and the authorization rule is split intoauthorize_peer— same behavior, testable.Compatibility
No wire, proto, storage-format or config change; an upgraded node and a
nextnode interoperate on the v1 route exactly as before. The behavioral deltas, all one-directional:the_sync_limits_admit_the_largest_message_the_protocol_can_producepins the relationship so the ceiling cannot be tightened into ordinary traffic.node/info,node/statusand__peer_addrappear a few hundred milliseconds later at startup (after the bootstrap round instead of before it). Nothing reads them in that window — the one thing that did,HttpSyncNetwork::new, is exactly what this fixes.Verification
cargo test -p dstack-gateway— 103 tests, all green (78 onnext; +25 here).cargo clippy -- -D warnings -D clippy::expect_used -D clippy::unwrap_used --allow unused_variablesandcargo fmt --check --allclean.The new coverage, all against the real code path:
sender_id == 0is refused;inst-amust not swallowinst-ab), builders and parsers round-trip, parsers refuse foreign namespaces.Related: #1029. Stacks cleanly with #1035 (verified:
git merge-treereports no conflicts).