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}