Vulnerability DatabaseGHSA-3qf7-j9r6-6qv3

GHSA-3qf7-j9r6-6qv3: 
Rust vulnerability analysis and mitigation

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.

Problem

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.

Where

crates/graphql-orm-router/src/server.rs:994-1014, :628-637

Suggested fix

Reject subscriptions off the trusted internal path, or enforce principal and expiry for HTTP streams.

Acceptance

  • A regression test fails before the change and passes after it.
  • Sibling code paths with the same shape are checked and covered.
  • Docs and changelog state the corrected behaviour. Filed as a private draft advisory. Fix on a private fork or temporary private branch and publish after release.

Follow-up verification (end-to-end static trace, with a proposed regression test; the test source was not compiled):

H. RTR-2 — CONFIRMED, and worse than stated (recommend High, no longer Plausible)

Trace: the HTTP path exists

  1. ORM/crates/graphql-orm-router/src/server.rs:994-1014: Hive subscriptions.enabled: true whenever config.subscriptions is set.
  2. 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.
  3. 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).
  4. 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).
  5. 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 runs
  6. server.rs:628-637: with no Authorization header, on_http_request proceeds anonymously. With a header, the bearer is authenticated once (:644-648).
  7. 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.
  8. server.rs:715-747: for a non-trusted request, on_graphql_analysis authorizes with VariableResolution::Preflight against that now-empty map.
  9. 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.
  10. For queries and mutations, on_execute re-checks with Complete (server.rs:767-814).
  11. For subscriptions it does not. 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).
  12. This is why the gateway sets TrustedInternalSubscription and injects variables through the reserved extension: so that Complete runs at analysis (server.rs:719-746). The HTTP path gets neither. Trace: expiry
  • The gateway requires a bearer with a usable expiry and tears down at expires_at (subscriptions.rs:536-590, spawn_expiry at :445-470).
  • On HTTP the principal is checked once. The pump in 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
  • A root field with @include(if: $v) is skipped entirely in Preflight (auth.rs:749-756 returns Deferred, and :705-713 only collects Included).
  • Hive's validation does not forbid directives on a subscription root (graphql-tools-0.5.8/src/validation/rules/single_field_subscriptions.rs).
  • If Hive's planner still emits a subscription node for a conditional root, this would be an anonymous bypass of any requirement. I did not trace the planner, so this needs a runtime check. Mitigating factors
  • The router forwards 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.
  • Use the same setup as authenticated_public_websocket_enforces_lifecycle_and_routes_upstream_websocket (:1496-1560), where productChanged requires products.{id}.events.
  • Token product-events holds only products.p1.events. Token short-lived lasts one second.
  • Raw 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

Related Rust vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-gp5v-m67f-gmgpHIGHN/A
  • Rust logoRust
  • deep-diff-forge-cli
NoYesNov 10, 2026
GHSA-rrfc-2x66-j7xhMEDIUMN/A
  • Rust logoRust
  • hatchzip
NoYesNov 10, 2026
GHSA-f7xj-6695-ww2qMEDIUMN/A
  • Rust logoRust
  • graphql-orm
NoYesNov 10, 2026
GHSA-439h-2cx8-xwgxHIGHN/A
  • Rust logoRust
  • graphql-orm-backup
NoYesNov 10, 2026
GHSA-3qf7-j9r6-6qv3HIGHN/A
  • Rust logoRust
  • graphql-orm-router
NoYesNov 10, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management