diff --git a/Cargo.lock b/Cargo.lock index 2921f38..c27a843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "graphql-orm-router" -version = "0.5.0" +version = "0.5.1" dependencies = [ "agql-auth", "arc-swap", diff --git a/crates/graphql-orm-router/CHANGELOG.md b/crates/graphql-orm-router/CHANGELOG.md index 387853e..95ccfe2 100644 --- a/crates/graphql-orm-router/CHANGELOG.md +++ b/crates/graphql-orm-router/CHANGELOG.md @@ -10,6 +10,19 @@ supersedes: [] # Changelog +## 0.5.1 - 2026-09-02 + +- Replaced the full duplicate of WebSocket subscription variables with a + bounded scalar-only authorization projection. Large data variables remain + single-copy, so operations such as chunked uploads stay within the private + transport's message limit. +- Preserved variable-dependent subscription authorization before subgraph work. + Client-supplied reserved metadata is replaced, and any authorization value + omitted by the projection or remaining-frame budget fails closed. + +No router configuration, GraphQL schema, descriptor, token, or stored-data +migration is required. + ## 0.5.0 - 2026-08-24 - Aligned the optional adapter to generic `agql-auth` 0.19.0 at reviewed merged diff --git a/crates/graphql-orm-router/Cargo.toml b/crates/graphql-orm-router/Cargo.toml index 9acc39b..e332701 100644 --- a/crates/graphql-orm-router/Cargo.toml +++ b/crates/graphql-orm-router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-router" -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version = "1.90" description = "Federated GraphQL router for graphql-orm and project-neutral subgraphs" diff --git a/crates/graphql-orm-router/MIGRATION.md b/crates/graphql-orm-router/MIGRATION.md index a0d401a..c110081 100644 --- a/crates/graphql-orm-router/MIGRATION.md +++ b/crates/graphql-orm-router/MIGRATION.md @@ -10,6 +10,16 @@ supersedes: [] # graphql-orm-router migration guide +## 0.5.0 to 0.5.1 + +No configuration, schema, token, descriptor, or stored-data migration is +required. Rebuild consumers at the reviewed 0.5.1 monorepo revision and run +their authenticated WebSocket acceptance tests. The router now sends only a +bounded scalar authorization projection beside the ordinary variables object; +large data variables are no longer duplicated on the private subscription +transport. Variable-dependent authorization still runs before subgraph work +and fails closed when a required value cannot fit that projection. + ## 0.4.0 to 0.5.0 Align direct `agql-auth` consumers to 0.19.0 at reviewed merged revision diff --git a/crates/graphql-orm-router/README.md b/crates/graphql-orm-router/README.md index 3536322..77d5dbe 100644 --- a/crates/graphql-orm-router/README.md +++ b/crates/graphql-orm-router/README.md @@ -26,7 +26,7 @@ This unpublished package is Git-only: ```toml [dependencies] -graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.5.0" } +graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.5.1" } ``` Enable `auth-agql` only when adapting a separately configured @@ -90,6 +90,13 @@ access, and stale JWKS cache use explicit deny-by-default policy. Static source configuration is deployment-owned; dynamic destinations are additionally subject to DNS, host, port, CIDR, redirect, and peer validation. +Authenticated subscriptions forward the ordinary variables object once. A +separate private authorization extension contains only a bounded projection of +small scalar variables; client-supplied values for that reserved extension are +always replaced. Large data variables are not duplicated across the private +transport, and a variable-dependent authorization decision fails closed if its +value cannot fit the projection or remaining frame budget. + ## Errors and operations Public errors are router-owned and sanitized: status does not reveal SDL, diff --git a/crates/graphql-orm-router/src/server.rs b/crates/graphql-orm-router/src/server.rs index b3e6829..9b2ac55 100644 --- a/crates/graphql-orm-router/src/server.rs +++ b/crates/graphql-orm-router/src/server.rs @@ -625,9 +625,11 @@ impl RouterPlugin for StaticGraphPlugin { let principal = payload.context.get_ref::(); // Hive has already moved supplied values into its coerced-variable // payload at this hook. The public WebSocket gateway therefore copies - // each operation's raw values into a reserved extension before it - // enters Hive. Only the authenticated private endpoint may activate - // that extension; ordinary HTTP clients cannot spoof it. + // a bounded projection of authorization-capable scalar values into a + // reserved extension before it enters Hive. Only the authenticated + // private endpoint may activate that extension; ordinary HTTP clients + // cannot spoof it. Values omitted by the bound fail closed under + // complete variable resolution. let trusted_subscription = payload .context .get_ref::() diff --git a/crates/graphql-orm-router/src/subscriptions.rs b/crates/graphql-orm-router/src/subscriptions.rs index 2d1a68b..f4821b2 100644 --- a/crates/graphql-orm-router/src/subscriptions.rs +++ b/crates/graphql-orm-router/src/subscriptions.rs @@ -26,6 +26,8 @@ use crate::{ pub(crate) const INTERNAL_SUBSCRIPTION_HEADER: &str = "x-graphql-orm-router-internal"; pub(crate) const INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION: &str = "graphqlOrmRouterVariables"; const GRAPHQL_TRANSPORT_WS: &str = "graphql-transport-ws"; +const MAX_AUTHORIZATION_VARIABLE_BYTES: usize = 4 * 1024; +const MAX_AUTHORIZATION_VARIABLE_VALUE_BYTES: usize = 1024; #[derive(Clone)] pub(crate) struct InternalSubscriptionEndpoint { @@ -731,7 +733,8 @@ async fn forward_subscribe( state.operations.insert(id.clone()); state.gateway.add_operation(); } - inject_operation_variables(&mut message); + let internal_max_bytes = state.borrow().gateway.config.max_client_message_bytes; + inject_operation_variables(&mut message, internal_max_bytes); let internal = state.borrow().internal_sink.clone(); if let Some(internal) = internal && internal @@ -747,24 +750,71 @@ async fn forward_subscribe( .terminate_with_public_close(1011, "Subscription transport unavailable") } -fn inject_operation_variables(message: &mut Value) { +fn inject_operation_variables(message: &mut Value, internal_max_bytes: usize) { let Some(payload) = message.get_mut("payload").and_then(Value::as_object_mut) else { return; }; - let variables = payload + let mut candidates = payload .get("variables") - .cloned() - .unwrap_or_else(|| json!({})); + .and_then(Value::as_object) + .into_iter() + .flat_map(|variables| variables.iter()) + .filter(|(_, value)| { + value.is_null() || value.is_boolean() || value.is_number() || value.is_string() + }) + .filter_map(|(name, value)| { + let encoded = value.to_string(); + (encoded.len() <= MAX_AUTHORIZATION_VARIABLE_VALUE_BYTES) + .then(|| (name.to_owned(), value.clone(), name.len() + encoded.len())) + }) + .collect::>(); + candidates.sort_by(|left, right| left.0.cmp(&right.0)); + + let mut projected = serde_json::Map::new(); + let mut projected_bytes = 0_usize; + for (name, value, encoded_bytes) in candidates { + if projected_bytes.saturating_add(encoded_bytes) > MAX_AUTHORIZATION_VARIABLE_BYTES { + break; + } + projected_bytes += encoded_bytes; + projected.insert(name, value); + } + let extensions = payload.entry("extensions").or_insert_with(|| json!({})); let Some(extensions) = extensions.as_object_mut() else { return; }; // The public gateway is the only writer trusted by the private Hive - // endpoint. Always replace a client-supplied value for this reserved key. + // endpoint. Replace client-supplied reserved metadata with a small scalar + // projection. Large data variables remain only in the standard variables + // object, while missing authorization values fail closed at analysis. extensions.insert( INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION.to_owned(), - variables, + Value::Object(projected), ); + + while message.to_string().len() > internal_max_bytes { + let Some(projected) = message + .get_mut("payload") + .and_then(Value::as_object_mut) + .and_then(|payload| payload.get_mut("extensions")) + .and_then(Value::as_object_mut) + .and_then(|extensions| extensions.get_mut(INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION)) + .and_then(Value::as_object_mut) + else { + break; + }; + let Some(name) = projected.keys().next_back().cloned() else { + message + .get_mut("payload") + .and_then(Value::as_object_mut) + .and_then(|payload| payload.get_mut("extensions")) + .and_then(Value::as_object_mut) + .map(|extensions| extensions.remove(INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION)); + break; + }; + projected.remove(&name); + } } async fn forward_complete( @@ -871,7 +921,7 @@ mod tests { } #[test] - fn subscription_gateway_overwrites_reserved_variable_metadata() { + fn subscription_gateway_replaces_reserved_metadata_with_bounded_scalars() { let mut message = json!({ "id": "operation", "type": "subscribe", @@ -885,8 +935,9 @@ mod tests { } }); - inject_operation_variables(&mut message); + inject_operation_variables(&mut message, 64 * 1024); + assert_eq!(message["payload"]["variables"], json!({"Id": "actual"})); assert_eq!( message["payload"]["extensions"][INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION], json!({"Id": "actual"}) @@ -894,6 +945,40 @@ mod tests { assert_eq!(message["payload"]["extensions"]["client"], true); } + #[test] + fn subscription_gateway_keeps_large_variable_payload_below_the_public_limit() { + let mut message = json!({ + "id": "operation", + "type": "subscribe", + "payload": { + "query": "subscription ($Content: String!) { event(content: $Content) }", + "variables": {"Content": "x".repeat(48 * 1024)}, + "extensions": { + INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION: {"Content": "spoofed"} + } + } + }); + let public_size = message.to_string().len(); + + inject_operation_variables(&mut message, 64 * 1024); + + let internal_size = message.to_string().len(); + assert!(public_size < 64 * 1024); + assert_eq!( + message["payload"]["variables"]["Content"] + .as_str() + .unwrap() + .len(), + 48 * 1024 + ); + assert!( + message["payload"]["extensions"][INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION] + .as_object() + .is_some_and(serde_json::Map::is_empty) + ); + assert!(internal_size < 64 * 1024); + } + #[test] fn connection_attempt_limiter_contains_churn_and_refills_gradually() { let start = Instant::now(); diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 5967426..e35d408 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -24,7 +24,7 @@ changes. | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | -| `graphql-orm-router` | `0.5.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | +| `graphql-orm-router` | `0.5.1` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | | `graphql-orm-storage` | `0.6.2` | `crates/graphql-orm-storage` | `local` | none |