
Cloud Vulnerability DB
A community-led vulnerabilities database
Audit finding RTR-2 (2026-11-09 audit), severity medium.
Audited revision: origin/main b7c8030f (core 0.111.0, ai 0.126.0, router 0.9.1). Line numbers refer to that revision. Single reviewer, static reading only. Reproduce with a failing test before changing code.
Hive-native HTTP subscriptions (SSE, multipart) bypass the WebSocket gateway: no mandatory bearer, no teardown at token expiry, no attempt limiter. Plausible; streaming not traced end to end.
crates/graphql-orm-router/src/server.rs:994-1014, :628-637
Reject subscriptions off the trusted internal path, or enforce principal and expiry for HTTP streams.
Follow-up verification (end-to-end static trace, with a proposed regression test; the test source was not compiled):
Trace: the HTTP path exists
ORM/crates/graphql-orm-router/src/server.rs:994-1014: Hive subscriptions.enabled: true whenever config.subscriptions is set.server.rs:450-466: the public app routes only Upgrade: websocket requests to the gateway. Everything else on the GraphQL path goes to hive_router::configure_ntex_app.HIVE/src/pipeline/mod.rs:147: response mode is negotiated from Accept. text/event-stream and multipart/mixed are stream modes (pipeline/header.rs:88-106, :211-213).mod.rs:412-422: a subscription is rejected only if subscriptions are disabled or the mode cannot stream. GET is allowed too; only mutations are blocked on GET (:399-409).mod.rs:432-444: only the long-lived client cap applies. The gateway's connection-attempt limiter (subscriptions.rs:329) is bypassed.
Trace: what authorization runsserver.rs:628-637: with no Authorization header, on_http_request proceeds anonymously. With a header, the bearer is authenticated once (:644-648).HIVE/src/pipeline/mod.rs:636-640: coerce_request_variables moves supplied variables out of graphql_params.variables (executor/variables/mod.rs, variables_map.remove). The router's own comment at server.rs:708-714 says the same.server.rs:715-747: for a non-trusted request, on_graphql_analysis authorizes with VariableResolution::Preflight against that now-empty map.auth.rs:442-446: a scope template whose argument is a variable cannot render. In Preflight this is Deferred, and auth.rs:366 treats Deferred as not denied.on_execute re-checks with Complete (server.rs:767-814).HIVE/src/executor/execution/plan.rs:255-437 handles the subscription branch and returns before the on_execute call at :493. Per-event resolution sets plugin_req_state: None with the comment "plugins for subscriptions are not yet supported" (:416-417).TrustedInternalSubscription and injects variables through the reserved extension: so that Complete runs at analysis (server.rs:719-746). The HTTP path gets neither.
Trace: expiryexpires_at (subscriptions.rs:536-590, spawn_expiry at :445-470).HIVE/src/pipeline/mod.rs:689-720 ends only on client disconnect, upstream end, or supergraph retirement.
What each kind of request can do
| Request | Result |
|---|---|
| No Authorization header, field is Public or SubgraphOnly | Stream starts (auth.rs:401-403). The gateway would close with 4401. |
| No header, field is Authenticated or scoped | Denied UNAUTHENTICATED at analysis (auth.rs:404-425). |
| Valid bearer, static scope template, or templated scope with an inline literal argument | Enforced at analysis. |
| Valid bearer, templated scope (for example products.{id}.events) with the argument passed as a variable | Not enforced. Any authenticated principal can subscribe to any id. |
| Bearer that expires mid-stream | Stream continues indefinitely. |
Unverified lead@include(if: $v) is skipped entirely in Preflight (auth.rs:749-756 returns Deferred, and :705-713 only collects Included).graphql-tools-0.5.8/src/validation/rules/single_field_subscriptions.rs).authorization to subgraphs (server.rs:976-978). A subgraph that enforces scopes itself still protects its data; the router layer is what is bypassed.docs/operations.md:152-156 already admits SSE and multipart bypass the gateway for continuity (#437), but not for authorization.
Regression tests — add to ORM/crates/graphql-orm-router/src/federation/wire_tests.rs.authenticated_public_websocket_enforces_lifecycle_and_routes_upstream_websocket (:1496-1560), where productChanged requires products.{id}.events.product-events holds only products.p1.events. Token short-lived lasts one second.TcpStream is needed because raw_http reads to end of stream.fn sse_subscribe(address: SocketAddr, bearer: &str, payload: Value) -> TcpStream {
let body = serde_json::to_vec(&payload).unwrap();
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap();
stream.set_read_timeout(Some(Duration::from_millis(250))).unwrap();
write!(stream,
"POST /api/graphql HTTP/1.1\r\nHost: {address}\r\nAccept: text/event-stream\r\n\
Authorization: Bearer {bearer}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
body.len()).unwrap();
stream.write_all(&body).unwrap();
stream
}
/// Reads until `needle` appears, the peer closes (Some(true)), or `limit` passes (None).
fn read_until(stream: &mut TcpStream, needle: &str, limit: Duration) -> (String, Option<bool>) {
let deadline = Instant::now() + limit;
let (mut seen, mut buffer) = (Vec::new(), [0_u8; 4096]);
while Instant::now() < deadline {
match stream.read(&mut buffer) {
Ok(0) => return (String::from_utf8_lossy(&seen).into_owned(), Some(true)),
Ok(read) => {
seen.extend_from_slice(&buffer[..read]);
if String::from_utf8_lossy(&seen).contains(needle) {
return (String::from_utf8_lossy(&seen).into_owned(), Some(false));
}
}
Err(_) => {}
}
}
(String::from_utf8_lossy(&seen).into_owned(), None)
}
#[test]
fn http_sse_subscription_enforces_variable_bound_scopes() {
// ...setup copied from authenticated_public_websocket_enforces_lifecycle_and_routes_upstream_websocket...
let opens_before = products.opens();
let mut stream = sse_subscribe(router.address, "product-events", json!({
"query": "subscription E($Id: ID!) { productChanged(id: $Id) { id name } }",
"variables": {"Id": "p2"}
}));
let (seen, _) = read_until(&mut stream, "FORBIDDEN", Duration::from_secs(3));
assert!(seen.contains("FORBIDDEN"), "router must deny products.p2.events over SSE: {seen}");
assert_eq!(products.opens(), opens_before, "denial must precede any upstream subscription");
}
#[test]
fn http_sse_subscription_ends_when_the_bearer_expires() {
// ...same setup...
let mut stream = sse_subscribe(router.address, "short-lived", json!({
"query": "subscription { productChanged(id: \"p1\") { id name } }"
}));
let (_, closed) = read_until(&mut stream, "\u{0}never", Duration::from_secs(5));
assert_eq!(closed, Some(true), "an HTTP stream must not outlive its 1 s credential");
}If the chosen fix is to refuse HTTP subscriptions off the trusted path, both tests reduce to asserting a non-stream error response.
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."