Skip to content

Commit a6eefcf

Browse files
authored
fix(network): preserve pipelined requests after chunked inspection (#3861)
* fix(network): preserve pipelined requests after chunked inspection Stop chunked MCP and JSON-RPC body reads at each framing boundary so the connection reader retains the next request for independent inspection. Keep payload reads bounded by the remaining chunk length and scan framing lines incrementally. Cover buffered prefixes, fragmented framing, trailers, and malformed input. Verify allowed and denied pipelined requests through both relay entry paths. Signed-off-by: Shiju <shiju@nvidia.com> * fix(network): share bounded HTTP body inspection with GraphQL Remove GraphQL's duplicate chunk decoder so all buffered HTTP inspectors preserve the next request on a persistent connection. Keep GraphQL's header checks, configured body limit and query classification. Cover GraphQL trailers and fragmented framing, and exercise subsequent request authorization for REST, GraphQL, MCP and JSON-RPC through both persistent relay entry paths. Signed-off-by: Shiju <shiju@nvidia.com> --------- Signed-off-by: Shiju <shiju@nvidia.com>
1 parent 2ad77ad commit a6eefcf

4 files changed

Lines changed: 395 additions & 207 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ support `params` matchers; generic JSON-RPC rules match only the method.
397397
JSON-RPC responses and server-to-client MCP messages on response or SSE streams
398398
are relayed but are not currently parsed for policy enforcement.
399399

400+
Buffered GraphQL, MCP, and JSON-RPC request inspection shares an HTTP body reader that consumes exactly one chunked message before normalizing its body to `Content-Length`. Chunk extensions and trailers do not become part of the decoded body. REST streams chunked framing with the same message boundary. On persistent inspected connections, bytes for a subsequent pipelined request stay in the connection reader and receive a separate policy decision.
401+
400402
Every `protocol: mcp` endpoint carries a canonical, nonempty `mcp.versions` allowlist drawn from OpenShell's exact revision registry: `2025-03-26`, `2025-06-18`, `2025-11-25`, and `2026-07-28`. Omitting the `mcp` object or its `versions` key resolves immediately to `["2025-11-25"]`. Normalization stores and serializes the materialized allowlist in semantic order, so adding a supported revision never widens a normalized policy. Explicit `mcp: null`, `versions: null`, and `versions: []` are invalid; at protobuf ingress, an empty repeated field means omission because repeated fields do not preserve presence. The registry is a closed set, so duplicate or padded values, unknown dates, and moving aliases such as `draft` or `latest` are rejected. A version names a core protocol revision; separately named extensions have no policy version selector.
401403

402404
For the 2025 revisions, a valid standalone `initialize` proposes a version in its body. Every subsequent HTTP request selects its inspection revision through one `MCP-Protocol-Version` header. An absent header selects the transport's `2025-03-26` compatibility fallback, which is permitted only when the endpoint allowlist contains it. The supervisor does not inspect initialization responses, bind `MCP-Session-Id`, or infer a version from an earlier request. Duplicate, empty, or unsupported version headers receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. The `2025-03-26` profile permits nonempty same-side top-level JSON-RPC batches capped at 64 members. Later profiles prohibit top-level arrays.

‎crates/openshell-supervisor-network/src/l7/graphql.rs‎

Lines changed: 68 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33

44
//! GraphQL-over-HTTP L7 inspection.
55
6-
use crate::l7::provider::{BodyLength, L7Provider, L7Request};
6+
use crate::l7::provider::{L7Provider, L7Request};
77
use apollo_parser::Parser;
88
use apollo_parser::cst;
9-
use miette::{IntoDiagnostic, Result, miette};
9+
use miette::{Result, miette};
1010
use serde::Serialize;
1111
use serde_json::Value;
1212
use std::collections::{HashMap, HashSet};
13-
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
13+
use tokio::io::{AsyncRead, AsyncWrite};
1414

1515
pub const DEFAULT_MAX_BODY_BYTES: usize = 64 * 1024;
1616

@@ -85,7 +85,7 @@ pub(crate) async fn inspect_graphql_request<C: AsyncRead + Unpin>(
8585
) -> Result<GraphqlRequestInfo> {
8686
let header_str = header_str(request)?;
8787
reject_unsupported_headers(header_str)?;
88-
let body = read_body_for_inspection(client, request, max_body_bytes).await?;
88+
let body = crate::l7::http::read_body_for_inspection(client, request, max_body_bytes).await?;
8989
Ok(classify_request(request, &body))
9090
}
9191

@@ -379,195 +379,6 @@ fn unique_persisted_query_id(
379379
Ok(selected.map(|(_, value)| value))
380380
}
381381

382-
async fn read_body_for_inspection<C: AsyncRead + Unpin>(
383-
client: &mut C,
384-
request: &mut L7Request,
385-
max_body_bytes: usize,
386-
) -> Result<Vec<u8>> {
387-
let header_end = request
388-
.raw_header
389-
.windows(4)
390-
.position(|w| w == b"\r\n\r\n")
391-
.map_or(request.raw_header.len(), |p| p + 4);
392-
let overflow = request.raw_header[header_end..].to_vec();
393-
394-
match request.body_length {
395-
BodyLength::None => Ok(Vec::new()),
396-
BodyLength::ContentLength(len) => {
397-
let len = usize::try_from(len)
398-
.map_err(|_| miette!("GraphQL request body length exceeds platform limit"))?;
399-
if len > max_body_bytes {
400-
return Err(miette!(
401-
"GraphQL request body exceeds {max_body_bytes} byte inspection limit"
402-
));
403-
}
404-
if overflow.len() > len {
405-
return Err(miette!(
406-
"GraphQL request contains more body bytes than Content-Length"
407-
));
408-
}
409-
let remaining = len - overflow.len();
410-
let mut body = overflow;
411-
if remaining > 0 {
412-
let start = body.len();
413-
body.resize(len, 0);
414-
client
415-
.read_exact(&mut body[start..])
416-
.await
417-
.into_diagnostic()?;
418-
}
419-
request.raw_header.truncate(header_end);
420-
request.raw_header.extend_from_slice(&body);
421-
Ok(body)
422-
}
423-
BodyLength::Chunked => {
424-
let body = read_chunked_body_for_inspection(
425-
client,
426-
request,
427-
header_end,
428-
overflow,
429-
max_body_bytes,
430-
)
431-
.await?;
432-
normalize_chunked_request_to_content_length(request, header_end, &body)?;
433-
Ok(body)
434-
}
435-
}
436-
}
437-
438-
fn normalize_chunked_request_to_content_length(
439-
request: &mut L7Request,
440-
header_end: usize,
441-
body: &[u8],
442-
) -> Result<()> {
443-
let header_str = std::str::from_utf8(&request.raw_header[..header_end])
444-
.map_err(|_| miette!("GraphQL HTTP headers contain invalid UTF-8"))?;
445-
let header_str = header_str
446-
.strip_suffix("\r\n\r\n")
447-
.ok_or_else(|| miette!("GraphQL HTTP headers missing terminator"))?;
448-
449-
let mut normalized = Vec::with_capacity(header_str.len() + body.len() + 32);
450-
for (idx, line) in header_str.split("\r\n").enumerate() {
451-
if idx > 0 {
452-
let name = line
453-
.split_once(':')
454-
.map(|(name, _)| name.trim().to_ascii_lowercase());
455-
if matches!(
456-
name.as_deref(),
457-
Some("transfer-encoding" | "content-length" | "trailer")
458-
) {
459-
continue;
460-
}
461-
}
462-
normalized.extend_from_slice(line.as_bytes());
463-
normalized.extend_from_slice(b"\r\n");
464-
}
465-
normalized.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes());
466-
normalized.extend_from_slice(body);
467-
468-
request.raw_header = normalized;
469-
request.body_length = BodyLength::ContentLength(body.len() as u64);
470-
Ok(())
471-
}
472-
473-
async fn read_chunked_body_for_inspection<C: AsyncRead + Unpin>(
474-
client: &mut C,
475-
request: &mut L7Request,
476-
header_end: usize,
477-
overflow: Vec<u8>,
478-
max_body_bytes: usize,
479-
) -> Result<Vec<u8>> {
480-
let mut raw = overflow;
481-
let mut decoded = Vec::new();
482-
let mut pos = 0usize;
483-
484-
loop {
485-
let size_line_end = loop {
486-
if let Some(end) = find_crlf(&raw, pos) {
487-
break end;
488-
}
489-
read_more(client, &mut raw, max_body_bytes).await?;
490-
};
491-
let size_line = std::str::from_utf8(&raw[pos..size_line_end])
492-
.into_diagnostic()
493-
.map_err(|_| miette!("Invalid UTF-8 in GraphQL chunk-size line"))?;
494-
let size_token = size_line
495-
.split(';')
496-
.next()
497-
.map(str::trim)
498-
.unwrap_or_default();
499-
let chunk_size = usize::from_str_radix(size_token, 16)
500-
.into_diagnostic()
501-
.map_err(|_| miette!("Invalid GraphQL chunk size token: {size_token:?}"))?;
502-
pos = size_line_end + 2;
503-
504-
if decoded.len().saturating_add(chunk_size) > max_body_bytes {
505-
return Err(miette!(
506-
"GraphQL request body exceeds {max_body_bytes} byte inspection limit"
507-
));
508-
}
509-
510-
if chunk_size == 0 {
511-
loop {
512-
let trailer_end = loop {
513-
if let Some(end) = find_crlf(&raw, pos) {
514-
break end;
515-
}
516-
read_more(client, &mut raw, max_body_bytes).await?;
517-
};
518-
let trailer_line = &raw[pos..trailer_end];
519-
pos = trailer_end + 2;
520-
if trailer_line.is_empty() {
521-
request.raw_header.truncate(header_end);
522-
request.raw_header.extend_from_slice(&raw[..pos]);
523-
return Ok(decoded);
524-
}
525-
}
526-
}
527-
528-
let chunk_end = pos
529-
.checked_add(chunk_size)
530-
.ok_or_else(|| miette!("GraphQL chunk size overflow"))?;
531-
let chunk_with_crlf_end = chunk_end
532-
.checked_add(2)
533-
.ok_or_else(|| miette!("GraphQL chunk size overflow"))?;
534-
while raw.len() < chunk_with_crlf_end {
535-
read_more(client, &mut raw, max_body_bytes).await?;
536-
}
537-
decoded.extend_from_slice(&raw[pos..chunk_end]);
538-
if raw.get(chunk_end..chunk_with_crlf_end) != Some(&b"\r\n"[..]) {
539-
return Err(miette!("GraphQL chunk payload missing terminating CRLF"));
540-
}
541-
pos = chunk_with_crlf_end;
542-
}
543-
}
544-
545-
async fn read_more<C: AsyncRead + Unpin>(
546-
client: &mut C,
547-
raw: &mut Vec<u8>,
548-
max_body_bytes: usize,
549-
) -> Result<()> {
550-
if raw.len() > max_body_bytes.saturating_mul(2).max(max_body_bytes) {
551-
return Err(miette!(
552-
"GraphQL chunked request body exceeds inspection framing limit"
553-
));
554-
}
555-
let mut buf = [0u8; 8192];
556-
let n = client.read(&mut buf).await.into_diagnostic()?;
557-
if n == 0 {
558-
return Err(miette!("GraphQL chunked body ended before terminator"));
559-
}
560-
raw.extend_from_slice(&buf[..n]);
561-
Ok(())
562-
}
563-
564-
fn find_crlf(buf: &[u8], start: usize) -> Option<usize> {
565-
buf.get(start..)?
566-
.windows(2)
567-
.position(|w| w == b"\r\n")
568-
.map(|p| start + p)
569-
}
570-
571382
fn header_str(request: &L7Request) -> Result<&str> {
572383
let header_end = request
573384
.raw_header
@@ -602,6 +413,8 @@ fn reject_unsupported_headers(headers: &str) -> Result<()> {
602413
#[cfg(test)]
603414
mod tests {
604415
use super::*;
416+
use crate::l7::provider::BodyLength;
417+
use tokio::io::AsyncReadExt;
605418

606419
fn request(method: &str, target: &str) -> L7Request {
607420
L7Request {
@@ -698,6 +511,68 @@ mod tests {
698511
);
699512
}
700513

514+
#[tokio::test]
515+
async fn chunked_graphql_preserves_pipelined_request() {
516+
use tokio::io::{AsyncWriteExt, BufReader};
517+
518+
let body = r#"{"query":"query Viewer { viewer { login } }"}"#;
519+
let next = "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n";
520+
for capacity in [1, 8192] {
521+
for trailers in ["", "X-Sig: ignored\r\n"] {
522+
let wire = format!(
523+
"POST /graphql HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n{:x};ext=yes\r\n{body}\r\n0\r\n{trailers}\r\n{next}",
524+
body.len()
525+
);
526+
let (mut sender, receiver) = tokio::io::duplex(8192);
527+
sender.write_all(wire.as_bytes()).await.unwrap();
528+
sender.shutdown().await.unwrap();
529+
// A one-byte buffer splits every CRLF; a large buffer makes
530+
// the complete next request available during body inspection.
531+
let mut reader = BufReader::with_capacity(capacity, receiver);
532+
let parsed = parse_graphql_http_request(
533+
&mut reader,
534+
DEFAULT_MAX_BODY_BYTES,
535+
crate::l7::path::CanonicalizeOptions::default(),
536+
)
537+
.await
538+
.unwrap()
539+
.unwrap();
540+
assert_eq!(parsed.info.error, None);
541+
assert_eq!(parsed.info.operations[0].fields, ["viewer"]);
542+
let mut remaining = String::new();
543+
reader.read_to_string(&mut remaining).await.unwrap();
544+
assert_eq!(
545+
remaining, next,
546+
"GraphQL inspection consumed the next request"
547+
);
548+
}
549+
}
550+
}
551+
552+
#[tokio::test]
553+
async fn graphql_inspection_preserves_configured_body_limit() {
554+
let body = br#"{"query":"query { viewer }"}"#;
555+
for chunked in [false, true] {
556+
let mut req = request("POST", "/graphql");
557+
let wire = if chunked {
558+
req.body_length = BodyLength::Chunked;
559+
format!(
560+
"{:x}\r\n{}\r\n0\r\n\r\n",
561+
body.len(),
562+
std::str::from_utf8(body).unwrap()
563+
)
564+
.into_bytes()
565+
} else {
566+
req.body_length = BodyLength::ContentLength(body.len() as u64);
567+
body.to_vec()
568+
};
569+
let error = inspect_graphql_request(&mut wire.as_slice(), &mut req, body.len() - 1)
570+
.await
571+
.expect_err("the configured body limit must apply to either HTTP framing");
572+
assert!(error.to_string().contains("inspection limit"));
573+
}
574+
}
575+
701576
#[tokio::test]
702577
async fn chunked_graphql_post_is_normalized_after_inspection() {
703578
let body = br#"{"query":"query Viewer { viewer { login } }"}"#;

0 commit comments

Comments
 (0)