|
3 | 3 |
|
4 | 4 | //! GraphQL-over-HTTP L7 inspection. |
5 | 5 |
|
6 | | -use crate::l7::provider::{BodyLength, L7Provider, L7Request}; |
| 6 | +use crate::l7::provider::{L7Provider, L7Request}; |
7 | 7 | use apollo_parser::Parser; |
8 | 8 | use apollo_parser::cst; |
9 | | -use miette::{IntoDiagnostic, Result, miette}; |
| 9 | +use miette::{Result, miette}; |
10 | 10 | use serde::Serialize; |
11 | 11 | use serde_json::Value; |
12 | 12 | use std::collections::{HashMap, HashSet}; |
13 | | -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; |
| 13 | +use tokio::io::{AsyncRead, AsyncWrite}; |
14 | 14 |
|
15 | 15 | pub const DEFAULT_MAX_BODY_BYTES: usize = 64 * 1024; |
16 | 16 |
|
@@ -85,7 +85,7 @@ pub(crate) async fn inspect_graphql_request<C: AsyncRead + Unpin>( |
85 | 85 | ) -> Result<GraphqlRequestInfo> { |
86 | 86 | let header_str = header_str(request)?; |
87 | 87 | 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?; |
89 | 89 | Ok(classify_request(request, &body)) |
90 | 90 | } |
91 | 91 |
|
@@ -379,195 +379,6 @@ fn unique_persisted_query_id( |
379 | 379 | Ok(selected.map(|(_, value)| value)) |
380 | 380 | } |
381 | 381 |
|
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 | | - |
571 | 382 | fn header_str(request: &L7Request) -> Result<&str> { |
572 | 383 | let header_end = request |
573 | 384 | .raw_header |
@@ -602,6 +413,8 @@ fn reject_unsupported_headers(headers: &str) -> Result<()> { |
602 | 413 | #[cfg(test)] |
603 | 414 | mod tests { |
604 | 415 | use super::*; |
| 416 | + use crate::l7::provider::BodyLength; |
| 417 | + use tokio::io::AsyncReadExt; |
605 | 418 |
|
606 | 419 | fn request(method: &str, target: &str) -> L7Request { |
607 | 420 | L7Request { |
@@ -698,6 +511,68 @@ mod tests { |
698 | 511 | ); |
699 | 512 | } |
700 | 513 |
|
| 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 | + |
701 | 576 | #[tokio::test] |
702 | 577 | async fn chunked_graphql_post_is_normalized_after_inspection() { |
703 | 578 | let body = br#"{"query":"query Viewer { viewer { login } }"}"#; |
|
0 commit comments