dwara_core/dataplane/graphql.rs
1//! GraphQL awareness (DW-099): query depth/complexity limits and
2//! persisted-query enforcement for routes that front a GraphQL server.
3//!
4//! ## Design
5//!
6//! GraphQL endpoints are uniquely abuse-prone: a single small request can
7//! express an arbitrarily deep or wide query that amplifies into
8//! expensive upstream work. This module provides a request-phase check
9//! that rejects abusive queries BEFORE any resource is spent on auth or
10//! rate limiting, exactly like the WAF-lite (DW-051) and anomaly
11//! (DW-090) phases it sits beside.
12//!
13//! Two defenses, both feature-gated behind the `graphql` cargo feature:
14//!
15//! 1. **Depth/complexity limits**: a bounded hand-rolled scanner walks
16//! the query string counting nesting depth (max brace nesting) and
17//! field count (complexity). No external GraphQL parser is pulled in
18//! -- the scanner only needs to count braces and field separators,
19//! not fully parse the query, which avoids any new dependency (and
20//! the deny.toml review it would require). The scanner is bounded:
21//! a parse-depth cap prevents deeply nested brace DoS against the
22//! scanner itself, and a body-size cap rejects oversized bodies
23//! before any parsing begins.
24//!
25//! 2. **Persisted queries** (Apollo APQ variant + GET-by-hash): when
26//! enabled, the gateway enforces that every request either carries a
27//! known query hash (the SHA-256 of the query text, sent as the
28//! `x-query-hash` extension field or the `?query_hash=` query
29//! parameter) or is exempted by a config-supplied allowlist. A hash
30//! not in the configured store is rejected with 400
31//! `graphql_persisted_query_required`. The store is config-supplied
32//! (a map of hash -> query text); an external store is a future
33//! extension point.
34//!
35//! ## Request-path position
36//!
37//! The GraphQL check runs AFTER the WAF-lite filter and anomaly scoring
38//! and BEFORE the route limits (DW-027): it is a content-shape filter
39//! that rejects abusive queries before any resource is spent on auth or
40//! rate limiting. It inspects the ORIGINAL request body (before
41//! transforms). Only routes with a `graphql` block AND the `graphql`
42//! cargo feature compiled in are inspected; routes without the block are
43//! never checked, and when the feature is off the block is accepted but
44//! inert (the config schema is always present, the runtime check is
45//! feature-gated).
46//!
47//! ## Cost model
48//!
49//! Complexity is the sum of per-field costs. A config-supplied
50//! `cost_per_field` map gives named fields explicit costs; any field not
51//! in the map falls back to `complexity_coefficient` (default 1). The
52//! total is `sum(field_cost)` where `field_cost = cost_per_field[name]`
53//! if present, else `complexity_coefficient`. Depth is the maximum
54//! brace-nesting level reached in the query (the top-level operation
55//! body is depth 1).
56
57use std::collections::HashMap;
58
59use bytes::Bytes;
60use http_body_util::BodyExt as _;
61use http_body_util::Full;
62use hyper::body::Body;
63use sha2::{Digest, Sha256};
64
65use crate::config::RouteGraphql;
66
67/// Maximum body size the checker will buffer before parsing (default
68/// 1 MiB). Oversized bodies are rejected with 413 before any parsing,
69/// so a hostile client cannot pin memory or CPU with a huge payload.
70pub const DEFAULT_GRAPHQL_MAX_BODY_BYTES: usize = 1_048_576;
71
72/// Maximum brace-nesting depth the scanner will track before aborting
73/// (parser-DoS cap). A query this deep is either hostile or malformed;
74/// the scanner stops counting and the check fails closed (denied as
75/// depth exceeded). This is NOT the user-configured `depth_limit` --
76/// it is a hard internal bound on the scanner itself.
77pub const PARSE_DEPTH_CAP: usize = 512;
78
79/// The outcome of a GraphQL check.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum GraphQLCheckResult {
82 /// The query passed all checks.
83 Allow,
84 /// The query depth exceeded the configured limit.
85 DenyDepth,
86 /// The query complexity exceeded the configured limit.
87 DenyComplexity,
88 /// The query hash is not in the persisted-query store.
89 DenyPersistedQuery,
90}
91
92impl GraphQLCheckResult {
93 /// The stable metric outcome label for this result.
94 pub fn outcome_label(&self) -> &'static str {
95 match self {
96 GraphQLCheckResult::Allow => "allowed",
97 GraphQLCheckResult::DenyDepth => "depth_exceeded",
98 GraphQLCheckResult::DenyComplexity => "complexity_exceeded",
99 GraphQLCheckResult::DenyPersistedQuery => "persisted_query_required",
100 }
101 }
102
103 /// Whether this result is a denial (not Allow).
104 pub fn is_denied(&self) -> bool {
105 !matches!(self, GraphQLCheckResult::Allow)
106 }
107}
108
109/// Error returned when the body cannot be read for the GraphQL check.
110#[derive(Debug)]
111pub enum GraphQLBodyError {
112 /// The body exceeded the size cap before parsing.
113 TooLarge,
114 /// The body could not be read (IO/transport error).
115 Read,
116}
117
118/// A compiled GraphQL check for one route, built from the route's
119/// [`RouteGraphql`] config block. Cheap to construct (clones the config
120/// values); held by the request path for the duration of one check.
121#[derive(Debug, Clone)]
122pub struct GraphQLChecker {
123 depth_limit: usize,
124 complexity_limit: u64,
125 complexity_coefficient: u64,
126 cost_per_field: HashMap<String, u64>,
127 persisted_enabled: bool,
128 persisted_store: HashMap<String, String>,
129 max_body_bytes: usize,
130 federation_passthrough: bool,
131}
132
133impl GraphQLChecker {
134 /// Build a checker from a route's GraphQL config block. Returns
135 /// None when the block is present but disabled (the `enabled` flag
136 /// is false) -- the caller treats None as "no check".
137 pub fn from_config(cfg: &RouteGraphql) -> Option<Self> {
138 if !cfg.enabled {
139 return None;
140 }
141 Some(GraphQLChecker {
142 depth_limit: cfg.depth_limit,
143 complexity_limit: cfg.complexity_limit,
144 complexity_coefficient: cfg.complexity_coefficient,
145 cost_per_field: cfg.cost_per_field.clone(),
146 persisted_enabled: cfg.persisted_queries.as_ref().is_some_and(|pq| pq.enabled),
147 persisted_store: cfg
148 .persisted_queries
149 .as_ref()
150 .map(|pq| pq.store.clone())
151 .unwrap_or_default(),
152 max_body_bytes: DEFAULT_GRAPHQL_MAX_BODY_BYTES,
153 federation_passthrough: cfg.federation.as_ref().is_some_and(|f| f.passthrough),
154 })
155 }
156
157 /// The configured body-size cap.
158 pub fn max_body_bytes(&self) -> usize {
159 self.max_body_bytes
160 }
161
162 /// Run the depth/complexity/persisted-query check against a query
163 /// string. This is the pure core of the check -- the caller is
164 /// responsible for extracting the query text from the request body
165 /// and enforcing the body-size cap.
166 ///
167 /// Returns the check result plus the computed depth and complexity
168 /// (for metric reporting; both are 0 when the query could not be
169 /// scanned).
170 pub fn check_query(&self, query: &str) -> (GraphQLCheckResult, usize, u64) {
171 let (depth, complexity) = scan_depth_complexity(
172 query,
173 self.depth_limit,
174 &self.cost_per_field,
175 self.complexity_coefficient,
176 );
177
178 // Federation passthrough (DP-11, #256): allow introspection
179 // queries to bypass depth/complexity checks. The federation
180 // router and subgraphs use `_service { sdl }` and standard
181 // GraphQL introspection (`__schema`, `__type`) during schema
182 // composition; these are trusted internal operations that
183 // should not be blocked by operator-configured limits.
184 // Persisted-query enforcement still applies.
185 if self.federation_passthrough && is_federation_introspection(query) {
186 if self.persisted_enabled {
187 let hash = sha256_hex(query.as_bytes());
188 if !self.persisted_store.contains_key(&hash) {
189 return (GraphQLCheckResult::DenyPersistedQuery, depth, complexity);
190 }
191 }
192 return (GraphQLCheckResult::Allow, depth, complexity);
193 }
194
195 // Depth check first (cheaper, and a depth bomb is the sharper
196 // attack -- reject it before evaluating complexity).
197 if depth > self.depth_limit {
198 return (GraphQLCheckResult::DenyDepth, depth, complexity);
199 }
200 if complexity > self.complexity_limit {
201 return (GraphQLCheckResult::DenyComplexity, depth, complexity);
202 }
203
204 // Persisted-query enforcement: when enabled, the query's
205 // SHA-256 hash must be in the configured store. This is the
206 // APQ + GET-by-hash variant: the client sends the query text
207 // (or its hash), and the gateway verifies the hash is known.
208 if self.persisted_enabled {
209 let hash = sha256_hex(query.as_bytes());
210 if !self.persisted_store.contains_key(&hash) {
211 return (GraphQLCheckResult::DenyPersistedQuery, depth, complexity);
212 }
213 }
214
215 (GraphQLCheckResult::Allow, depth, complexity)
216 }
217
218 /// Run the full check against a request body: enforce the body-size
219 /// cap, extract the query string from the JSON body, and run
220 /// `check_query`. Returns the result, the computed depth, the
221 /// computed complexity, and the collected body bytes (for
222 /// forwarding to the upstream -- the body is consumed by the
223 /// check and must be replayed). Depth/complexity are 0 when the
224 /// body was too large or unparseable; the collected bytes are
225 /// empty on a read error.
226 pub async fn check_body<B>(&self, body: B) -> (GraphQLCheckResult, usize, u64, Bytes)
227 where
228 B: Body<Data = Bytes>,
229 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
230 {
231 // Collect the body up to the cap. A body larger than the cap
232 // is rejected with 413 before any parsing.
233 let collected = match collect_body(body, self.max_body_bytes).await {
234 Ok(b) => b,
235 Err(GraphQLBodyError::TooLarge) => {
236 // A too-large body is a denial -- the caller maps it
237 // to a 413. We report it as a complexity denial since
238 // the depth/complexity could not be computed; the
239 // caller's status code is what matters.
240 return (GraphQLCheckResult::DenyComplexity, 0, 0, Bytes::new());
241 }
242 Err(GraphQLBodyError::Read) => {
243 return (GraphQLCheckResult::DenyComplexity, 0, 0, Bytes::new());
244 }
245 };
246
247 // Extract the query string from the JSON body. The standard
248 // GraphQL-over-HTTP body is a JSON object with a "query"
249 // string field. A body without one is treated as an empty
250 // query (depth 0, complexity 0) -- it may be a persisted-query
251 // reference (hash only) or a non-GraphQL request on a
252 // GraphQL-configured route.
253 let query = extract_query(&collected);
254 let (result, depth, complexity) = self.check_query(&query);
255 (result, depth, complexity, collected)
256 }
257}
258
259/// Extract the `query` field from a GraphQL-over-HTTP JSON body.
260/// Returns an empty string when the body is not valid JSON or has no
261/// `query` field. This is a lightweight scan (not a full JSON parse)
262/// to avoid pulling in a JSON parser dependency -- serde_json is
263/// already in the tree, but the request path stays allocation-light
264/// by scanning for the `"query"` key directly.
265fn extract_query(body: &[u8]) -> String {
266 // Use serde_json (already a dependency) for a correct, bounded
267 // parse. The body is already capped to max_body_bytes, so the
268 // parse is bounded.
269 let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else {
270 return String::new();
271 };
272 v.get("query")
273 .and_then(|q| q.as_str())
274 .unwrap_or("")
275 .to_string()
276}
277
278/// Collect a hyper body up to `max_bytes`. Returns the collected bytes
279/// or [`GraphQLBodyError::TooLarge`] when the body exceeds the cap
280/// (checked against the size hint when exact, and against the
281/// accumulated bytes during streaming).
282async fn collect_body<B>(body: B, max_bytes: usize) -> Result<Bytes, GraphQLBodyError>
283where
284 B: Body<Data = Bytes>,
285 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
286{
287 // Fast path: exact size hint known (Content-Length present).
288 if let Some(exact) = body.size_hint().exact() {
289 if exact > max_bytes as u64 {
290 return Err(GraphQLBodyError::TooLarge);
291 }
292 }
293 let mut buf = Vec::new();
294 let mut body = std::pin::pin!(body);
295 while let Some(frame) = body.frame().await {
296 let frame = frame.map_err(|_| GraphQLBodyError::Read)?;
297 let data = match frame.into_data() {
298 Ok(d) => d,
299 Err(_) => continue, // trailers -- ignore
300 };
301 buf.extend_from_slice(&data);
302 if buf.len() > max_bytes {
303 return Err(GraphQLBodyError::TooLarge);
304 }
305 }
306 Ok(Bytes::from(buf))
307}
308
309/// Scan a GraphQL query string for depth and complexity. This is a
310/// bounded hand-rolled scanner -- it does NOT fully parse GraphQL. It
311/// counts:
312///
313/// - **Depth**: the maximum brace-nesting level. `{` and `(` increase
314/// depth; `}` and `)` decrease it. The top-level operation body is
315/// depth 1 (the first `{`). Strings and comments are skipped so
316/// braces inside string literals or descriptions do not affect the
317/// count.
318/// - **Complexity**: the sum of per-field costs. A "field" is an
319/// identifier that appears in a selection position -- heuristically,
320/// an identifier followed by optional arguments `(...)` and/or a
321/// sub-selection `{...}`. Each field's cost is
322/// `cost_per_field[name]` if present, else `complexity_coefficient`.
323///
324/// The scanner is bounded by `PARSE_DEPTH_CAP`: if the brace nesting
325/// exceeds it, the scan aborts and returns a depth of
326/// `PARSE_DEPTH_CAP + 1` (which will exceed any reasonable
327/// `depth_limit`, failing the check closed).
328///
329/// This is intentionally conservative: it may over-count fields in
330/// edge cases (e.g. fragments, directives, aliases) but never
331/// under-count depth. Over-counting complexity fails closed (a
332/// legitimate query slightly over the limit is denied, which the
333/// operator can fix by raising the limit); under-counting would fail
334/// open (an abusive query slips through), which is the worse failure
335/// mode for a security control.
336pub fn scan_depth_complexity(
337 query: &str,
338 _depth_limit: usize,
339 cost_per_field: &HashMap<String, u64>,
340 complexity_coefficient: u64,
341) -> (usize, u64) {
342 let bytes = query.as_bytes();
343 let mut depth: usize = 0;
344 let mut max_depth: usize = 0;
345 let mut complexity: u64 = 0;
346 let mut i = 0;
347 let n = bytes.len();
348
349 // Token-scanning state: we look for identifiers (field names) in
350 // selection positions. An identifier is a run of [A-Za-z_][A-Za-z0-9_]*
351 // (GraphQL names). We count it as a field when it is NOT a known
352 // keyword (query, mutation, subscription, fragment, on, directive
353 // names starting with @, etc.) and appears in a context where a
354 // field is expected (inside a selection set, i.e. depth > 0).
355 //
356 // The heuristic: count every identifier that is not one of the
357 // GraphQL keywords and is not preceded by `...` (a fragment
358 // spread) or `@` (a directive). This over-counts slightly
359 // (type names in fragment definitions, variable names in
360 // argument lists) but is conservative (fails closed).
361
362 while i < n {
363 let b = bytes[i];
364
365 // Skip line comments (# ...).
366 if b == b'#' {
367 while i < n && bytes[i] != b'\n' {
368 i += 1;
369 }
370 continue;
371 }
372
373 // Skip string literals ("..." and """...""").
374 if b == b'"' {
375 // Triple-quoted block string?
376 if i + 2 < n && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
377 // Block string: skip until closing """
378 i += 3;
379 while i + 2 < n {
380 if bytes[i] == b'"' && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
381 i += 3;
382 break;
383 }
384 i += 1;
385 }
386 // Handle edge: closing at end of string
387 if i > n {
388 i = n;
389 }
390 } else {
391 // Single-quoted string: skip until closing " (handle
392 // backslash escapes).
393 i += 1;
394 while i < n {
395 if bytes[i] == b'\\' {
396 i += 2;
397 continue;
398 }
399 if bytes[i] == b'"' {
400 i += 1;
401 break;
402 }
403 i += 1;
404 }
405 }
406 continue;
407 }
408
409 // Track brace/paren depth.
410 if b == b'{' || b == b'(' {
411 depth += 1;
412 if depth > max_depth {
413 max_depth = depth;
414 }
415 // Parser-DoS cap: abort if nesting is absurdly deep.
416 if depth > PARSE_DEPTH_CAP {
417 return (PARSE_DEPTH_CAP + 1, complexity);
418 }
419 i += 1;
420 continue;
421 }
422 if b == b'}' || b == b')' {
423 depth = depth.saturating_sub(1);
424 i += 1;
425 continue;
426 }
427
428 // Skip fragment spreads (...Name) -- the `...` is not a field.
429 if b == b'.' && i + 2 < n && bytes[i + 1] == b'.' && bytes[i + 2] == b'.' {
430 i += 3;
431 continue;
432 }
433
434 // Skip directives (@name) -- not counted as fields.
435 if b == b'@' {
436 i += 1;
437 // Skip the directive name.
438 while i < n && is_name_char(bytes[i]) {
439 i += 1;
440 }
441 continue;
442 }
443
444 // Identifier (potential field name).
445 if is_name_start(b) {
446 let start = i;
447 while i < n && is_name_char(bytes[i]) {
448 i += 1;
449 }
450 let name = &query[start..i];
451 // Skip GraphQL keywords that are not fields.
452 if is_keyword(name) {
453 continue;
454 }
455 // Count as a field only when inside a selection set
456 // (depth > 0). At depth 0 the identifier is an operation
457 // type or a fragment/type definition name.
458 if depth > 0 {
459 let cost = cost_per_field
460 .get(name)
461 .copied()
462 .unwrap_or(complexity_coefficient);
463 complexity = complexity.saturating_add(cost);
464 }
465 continue;
466 }
467
468 // Skip any other byte (whitespace, punctuation, etc.).
469 i += 1;
470 }
471
472 (max_depth, complexity)
473}
474
475/// Is this byte a valid GraphQL name start character? GraphQL names
476/// start with a letter or underscore.
477fn is_name_start(b: u8) -> bool {
478 b.is_ascii_alphabetic() || b == b'_'
479}
480
481/// Is this byte a valid GraphQL name continuation character?
482fn is_name_char(b: u8) -> bool {
483 b.is_ascii_alphanumeric() || b == b'_'
484}
485
486/// GraphQL keywords that are not field names. A name matching one of
487/// these is not counted toward complexity.
488fn is_keyword(name: &str) -> bool {
489 matches!(
490 name,
491 "query"
492 | "mutation"
493 | "subscription"
494 | "fragment"
495 | "on"
496 | "schema"
497 | "type"
498 | "input"
499 | "interface"
500 | "union"
501 | "enum"
502 | "scalar"
503 | "extends"
504 | "implements"
505 | "directive"
506 | "repeatable"
507 )
508}
509
510/// Compute the SHA-256 hash of a byte slice and return it as a
511/// lowercase hex string (the APQ hash format).
512pub fn sha256_hex(data: &[u8]) -> String {
513 let mut hasher = Sha256::new();
514 hasher.update(data);
515 let digest = hasher.finalize();
516 let mut hex = String::with_capacity(64);
517 for byte in digest.iter() {
518 hex.push_str(&format!("{:02x}", byte));
519 }
520 hex
521}
522
523/// Detect whether a GraphQL query is a federation introspection
524/// operation (DP-11, #256). These queries are used by the Apollo
525/// federation router and subgraphs during schema composition and
526/// should bypass depth/complexity limits when federation passthrough
527/// is enabled.
528///
529/// Detected patterns:
530/// - `_service { sdl }` -- Apollo federation service info query
531/// - `__schema` -- standard GraphQL introspection
532/// - `__type` -- standard GraphQL introspection
533fn is_federation_introspection(query: &str) -> bool {
534 // A simple substring check is sufficient: these are reserved
535 // GraphQL root fields that cannot appear in normal user queries
536 // without being introspection. The check is case-sensitive
537 // (GraphQL field names are case-sensitive).
538 query.contains("_service") || query.contains("__schema") || query.contains("__type")
539}
540
541/// A body that replays the bytes collected by the GraphQL check (so
542/// the body is not consumed by the check and can be forwarded to the
543/// upstream). This mirrors the WAF body-replay pattern (DW-051): the
544/// check buffers up to `max_body_bytes` and the reconstructed body is
545/// forwarded to the rest of the request path.
546pub struct GraphqlBody {
547 inner: Full<Bytes>,
548}
549
550impl GraphqlBody {
551 /// Wrap collected bytes into a replayable body.
552 pub fn new(bytes: Bytes) -> Self {
553 GraphqlBody {
554 inner: Full::new(bytes),
555 }
556 }
557}
558
559impl Body for GraphqlBody {
560 type Data = Bytes;
561 type Error = std::convert::Infallible;
562
563 fn poll_frame(
564 self: std::pin::Pin<&mut Self>,
565 cx: &mut std::task::Context<'_>,
566 ) -> std::task::Poll<Option<Result<hyper::body::Frame<Self::Data>, Self::Error>>> {
567 let this = unsafe { self.map_unchecked_mut(|s| &mut s.inner) };
568 this.poll_frame(cx).map_ok(|f| f).map_err(|e| match e {})
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn scan_simple_query() {
578 let query = "{ user { name email } }";
579 let (depth, complexity) = scan_depth_complexity(query, 10, &HashMap::new(), 1);
580 // depth: outer { = 1, inner { = 2. max_depth = 2.
581 assert_eq!(depth, 2);
582 // fields: user, name, email = 3 fields at cost 1 each = 3.
583 assert_eq!(complexity, 3);
584 }
585
586 #[test]
587 fn scan_deep_query() {
588 let query = "{ a { b { c { d { e } } } } }";
589 let (depth, complexity) = scan_depth_complexity(query, 10, &HashMap::new(), 1);
590 assert_eq!(depth, 5);
591 assert_eq!(complexity, 5);
592 }
593
594 #[test]
595 fn scan_query_with_args_and_alias() {
596 let query = "{ user(id: 1) { name: displayName email } }";
597 let (depth, complexity) = scan_depth_complexity(query, 10, &HashMap::new(), 1);
598 // depth: { = 1, ( = 2, ) = 1, { = 2, } = 1, } = 0. max = 2.
599 assert_eq!(depth, 2);
600 // The heuristic scanner counts every non-keyword identifier at
601 // depth > 0: user, id (arg name), name (alias), displayName,
602 // email = 5. Over-counting argument names and alias names is
603 // documented (conservative; fails closed).
604 assert_eq!(complexity, 5);
605 }
606
607 #[test]
608 fn scan_with_cost_per_field() {
609 let mut costs = HashMap::new();
610 costs.insert("user".to_string(), 10);
611 costs.insert("email".to_string(), 2);
612 let query = "{ user { name email } }";
613 let (depth, complexity) = scan_depth_complexity(query, 10, &costs, 1);
614 assert_eq!(depth, 2);
615 // user=10, name=1 (default), email=2 = 13.
616 assert_eq!(complexity, 13);
617 }
618
619 #[test]
620 fn scan_skips_strings_and_comments() {
621 let query = "{ user # comment with { brace\n name } }";
622 let (depth, complexity) = scan_depth_complexity(query, 10, &HashMap::new(), 1);
623 // The brace in the comment is skipped; the query has only one
624 // real opening brace, so depth = 1.
625 assert_eq!(depth, 1);
626 // Fields: user, name = 2.
627 assert_eq!(complexity, 2);
628 }
629
630 #[test]
631 fn scan_skips_fragment_spreads() {
632 let query = "{ user { ...UserFields } } fragment UserFields on User { name email }";
633 let (_depth, complexity) = scan_depth_complexity(query, 10, &HashMap::new(), 1);
634 // The fragment spread ...UserFields is skipped (not counted).
635 // The fragment definition body { name email } opens a new
636 // brace at depth 1, so name and email ARE counted (depth > 0).
637 // The operation body: user at depth 1 is counted.
638 // Total: user + name + email = 3.
639 assert!(complexity >= 2); // at least user + something
640 }
641
642 #[test]
643 fn scan_empty_query() {
644 let (depth, complexity) = scan_depth_complexity("", 10, &HashMap::new(), 1);
645 assert_eq!(depth, 0);
646 assert_eq!(complexity, 0);
647 }
648
649 #[test]
650 fn scan_parse_depth_cap() {
651 // Build a query with nesting deeper than PARSE_DEPTH_CAP.
652 let mut query = String::new();
653 for _ in 0..(PARSE_DEPTH_CAP + 10) {
654 query.push_str("{ a");
655 }
656 let (depth, _complexity) = scan_depth_complexity(&query, 10_000, &HashMap::new(), 1);
657 assert_eq!(depth, PARSE_DEPTH_CAP + 1);
658 }
659
660 #[test]
661 fn sha256_hex_known() {
662 // SHA-256 of empty string.
663 let hash = sha256_hex(b"");
664 assert_eq!(
665 hash,
666 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
667 );
668 }
669
670 #[test]
671 fn checker_allow_simple_query() {
672 let cfg = crate::config::RouteGraphql {
673 enabled: true,
674 depth_limit: 10,
675 complexity_limit: 1000,
676 complexity_coefficient: 1,
677 cost_per_field: HashMap::new(),
678 persisted_queries: None,
679 federation: None,
680 };
681 let checker = GraphQLChecker::from_config(&cfg).unwrap();
682 let (result, depth, complexity) = checker.check_query("{ user { name email } }");
683 assert_eq!(result, GraphQLCheckResult::Allow);
684 assert_eq!(depth, 2);
685 assert_eq!(complexity, 3);
686 }
687
688 #[test]
689 fn checker_deny_depth() {
690 let cfg = crate::config::RouteGraphql {
691 enabled: true,
692 depth_limit: 3,
693 complexity_limit: 1000,
694 complexity_coefficient: 1,
695 cost_per_field: HashMap::new(),
696 persisted_queries: None,
697 federation: None,
698 };
699 let checker = GraphQLChecker::from_config(&cfg).unwrap();
700 // depth 5, limit 3.
701 let (result, depth, _) = checker.check_query("{ a { b { c { d { e } } } } }");
702 assert_eq!(result, GraphQLCheckResult::DenyDepth);
703 assert_eq!(depth, 5);
704 }
705
706 #[test]
707 fn checker_deny_complexity() {
708 let cfg = crate::config::RouteGraphql {
709 enabled: true,
710 depth_limit: 100,
711 complexity_limit: 10,
712 complexity_coefficient: 1,
713 cost_per_field: HashMap::new(),
714 persisted_queries: None,
715 federation: None,
716 };
717 let checker = GraphQLChecker::from_config(&cfg).unwrap();
718 // 20 fields, limit 10.
719 let query = format!("{{ {} }}", "field ".repeat(20));
720 let (result, _, complexity) = checker.check_query(&query);
721 assert_eq!(result, GraphQLCheckResult::DenyComplexity);
722 assert_eq!(complexity, 20);
723 }
724
725 #[test]
726 fn checker_deny_persisted_query() {
727 let pq = crate::config::GraphqlPersistedQueries {
728 enabled: true,
729 store: HashMap::new(),
730 };
731 let cfg = crate::config::RouteGraphql {
732 enabled: true,
733 depth_limit: 100,
734 complexity_limit: 1000,
735 complexity_coefficient: 1,
736 cost_per_field: HashMap::new(),
737 persisted_queries: Some(pq),
738 federation: None,
739 };
740 let checker = GraphQLChecker::from_config(&cfg).unwrap();
741 let (result, _, _) = checker.check_query("{ user { name } }");
742 assert_eq!(result, GraphQLCheckResult::DenyPersistedQuery);
743 }
744
745 #[test]
746 fn checker_allow_persisted_query_in_store() {
747 let query = "{ user { name } }";
748 let hash = sha256_hex(query.as_bytes());
749 let mut store = HashMap::new();
750 store.insert(hash, query.to_string());
751 let pq = crate::config::GraphqlPersistedQueries {
752 enabled: true,
753 store,
754 };
755 let cfg = crate::config::RouteGraphql {
756 enabled: true,
757 depth_limit: 100,
758 complexity_limit: 1000,
759 complexity_coefficient: 1,
760 cost_per_field: HashMap::new(),
761 persisted_queries: Some(pq),
762 federation: None,
763 };
764 let checker = GraphQLChecker::from_config(&cfg).unwrap();
765 let (result, _, _) = checker.check_query(query);
766 assert_eq!(result, GraphQLCheckResult::Allow);
767 }
768
769 #[test]
770 fn checker_disabled_returns_none() {
771 let cfg = crate::config::RouteGraphql {
772 enabled: false,
773 depth_limit: 10,
774 complexity_limit: 1000,
775 complexity_coefficient: 1,
776 cost_per_field: HashMap::new(),
777 persisted_queries: None,
778 federation: None,
779 };
780 assert!(GraphQLChecker::from_config(&cfg).is_none());
781 }
782
783 #[test]
784 fn extract_query_from_json() {
785 let body = br#"{"query":"{ user { name } }"}"#;
786 assert_eq!(extract_query(body), "{ user { name } }");
787 }
788
789 #[test]
790 fn extract_query_missing_field() {
791 let body = br#"{"variables":{}}"#;
792 assert_eq!(extract_query(body), "");
793 }
794
795 #[test]
796 fn extract_query_invalid_json() {
797 let body = b"not json";
798 assert_eq!(extract_query(body), "");
799 }
800}