feat(gateway)!: route standalone WS path through the shared trust gate#1391
Conversation
Phase 1c prerequisite (#1356): the standalone-gateway WebSocket path (run_gateway_adapter) now enforces L2 (channel scope) and L3 (identity) via the same per-platform trust registry as the unified path, instead of its own inline allowlist checks. - gateway.rs: extract gate_gateway_event — the shared ingress gate (gate_incoming + DenyIdentity throttled request-access echo + DenyScope silent drop) used by BOTH process_gateway_event (unified) and the WS loop. Gate runs before slash handling so untrusted senders cannot execute /reset|/cancel|/config. should_skip_event on the WS path is neutered to structural gating only (bot admission + @mention), matching the unified path. - main.rs: seed the registry with the [gateway] section's trust for its platform (gateway_section_trust — same resolve_allow_all semantics the old inline filter used). Precedence: GATEWAY_* env (uniform seed) < [gateway] section < [<platform>] section (platform_trust_override). - Dead code removed: GatewayParams and GatewayEventContext channel/user allowlist fields (unified ones were already unread post-#1297; WS ones move to the registry), plus the unified block's GATEWAY_ALLOW_ALL_* env reads (the registry's uniform seed already consumes them). - Docs: the six mode-scoping callouts (line/wecom/google-chat/teams/ config-reference ×2) now describe the consolidated trust resolution. BREAKING CHANGE: in standalone-gateway deployments that define BOTH [gateway] allowlists AND a first-class [<platform>] trust section, the platform section now wins on the WS path (documented target state: config-first, platform section is the most specific). Previously the platform section had no effect on that path. Migration: if your WS deployment relies on [gateway].allowed_users while also carrying a [<platform>] section with different values, align the platform section (it is now authoritative for that platform). Tests: gateway_section_trust_mirrors_ws_filter_semantics + gateway_trust_seed_precedence_env_lt_gateway_lt_platform (main.rs) + ws_path_filter_is_structural_only (gateway.rs). Refs #1356.
This comment has been minimized.
This comment has been minimized.
Self-review catch: the gate helper awaited send_message inline in the
WS select arm. In streaming mode send_gateway_reply registers a pending
request and waits (5s timeout) for a GatewayResponse — which is
dispatched by that same loop arm. Every DenyIdentity would therefore
stall ALL event processing for the full reply timeout; distinct
untrusted senders on a public bot could chain these stalls into a DoS.
Restructure: gate_gateway_event is now a sync decision helper returning
GateOutcome { Allow | Deny { echo } } — the caller chooses delivery.
The WS path spawns the echo onto the JoinSet (never blocking the loop,
matching the existing fire-and-forget pattern for slash responses);
the unified path (axum/bridge task context) awaits it directly as
before. Rationale documented on the enum and both call sites.
|
Note LGTM ✅ — Clean architecture consolidation that unifies trust enforcement across both deployment modes, with excellent self-review catching a real deadlock/DoS vector. What This PR DoesRoutes the standalone-gateway WebSocket path through the same shared per-platform trust registry that the unified path already uses, eliminating the last ingress that bypassed it. This closes the gap where How It Works
Findings
What's Good (🟢)
Baseline Check
5️⃣ Three Reasons We Might Not Need This PR
All three are reasonably addressed — the PR is justified. |
What problem does this solve?
The standalone-gateway WebSocket path was the last ingress that bypassed the shared trust registry (#1356 Phase 1c prerequisite).
run_gateway_adapterenforced channel/user allowlists via its own inline filter fed only by[gateway]values — so the per-platform[<platform>]trust sections (shipped across #1297–#1385) had no effect on standalone deployments, and six doc callouts had to carry mode-scoping caveats explaining that gap.Before vs after
How It Works
gate_gateway_event(new shared helper ingateway.rs):gate_incoming+DenyIdentity→ throttled request-access echo /DenyScope→ silent drop. Bothprocess_gateway_event(unified) and the WS loop call it — single deny-handling implementation, no drift between modes. On the WS path it runs before slash handling, so untrusted senders cannot execute/reset//cancel//config.main.rs): the[gateway]section's trust values are inserted for its platform viagateway_section_trust()— sameresolve_allow_allsemantics the old inline filter used (behavior-preserving for the common case). Seeding order gives the documented precedence:GATEWAY_*env (uniform seed) <[gateway]<[<platform>](platform_trust_override, which already runs in both modes).GatewayParams/GatewayEventContextchannel/user fields (the unified ones were already unread; the WS ones moved to the registry) and the unified block'sGATEWAY_ALLOW_ALL_*env reads (the registry's uniform seed already consumes those vars).In standalone-gateway deployments that define both
[gateway]allowlists and a first-class[<platform>]trust section with different values, the platform section now wins on the WS path (previously it was silently ignored there). This is the documented target state — config-first, most-specific section is authoritative. Migration: align the[<platform>]section values; they now govern that platform in both modes.Known edge (documented, not changed): a standalone gateway forwarding events for platforms other than
[gateway].platform— those events now resolve against the uniformGATEWAY_*env seed (or that platform's own section) instead of the[gateway]allowlists. Multi-platform standalone setups should use per-platform sections.Self-review (round 2 commit)
Adversarial pass over my own round-1 implementation caught a real bug:
send_messageinline inside the WS select arm. In streaming modesend_gateway_replywaits (5s timeout) on aGatewayResponsedispatched by that same loop arm — everyDenyIdentitywould stall all event processing for the full timeout, and distinct untrusted senders could chain stalls into a DoS.gate_gateway_eventis now a sync decision helper returningGateOutcome::{Allow, Deny{echo}}; the WS path spawns the echo onto its JoinSet (matching the existing fire-and-forget pattern for slash responses), the unified path awaits directly (axum/bridge context, safe). Rationale documented at the enum and both call sites.[gateway]registry seed runs after secret substitution (line 443 vs 347); registry insert/lookup both lowercase-normalize (no casing mismatch between[gateway].platformand event labels); the gate's spawned echo uses the same throttle (echo_allowed) as before, so amplification behavior is unchanged.Validation
-D warningscargo test -p openab-core --all-features— 672 pass (1 pre-existing macOS-only failure:secrets::tests::resolve_exec_nonzero_exit)cargo test --features unified --bin openab— 18 pass (includes the 3 new tests)gateway_section_trust_mirrors_ws_filter_semantics(section → TrustConfig semantics),gateway_trust_seed_precedence_env_lt_gateway_lt_platform(pins the three-layer precedence),ws_path_filter_is_structural_only(unknown users/channels pass the structural filter; bot/@mention gating stays)Refs #1356 (Phase 1c prerequisite — unblocks #1357 double-gate elimination).