Skip to content

Commit 560f647

Browse files
committed
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>
1 parent 8285ea8 commit 560f647

3 files changed

Lines changed: 117 additions & 211 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ 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-
Chunked MCP and JSON-RPC request inspection consumes exactly one HTTP message before normalizing its body to `Content-Length`. Bytes for a subsequent pipelined request stay in the connection reader and receive a separate policy decision. Chunk extensions and trailers do not become part of the decoded body.
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.
401401

402402
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.
403403

‎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)