Skip to main content

fraiseql_core/graphql/
complexity.rs

1// GraphQL query complexity analysis — AST-based to prevent DoS attacks.
2//
3// Uses `graphql-parser` to walk the document tree, correctly handling
4// operation names, arguments, fragment spreads, and aliases (which a
5// character-scan approach cannot distinguish from field names).
6
7use graphql_parser::query::{
8    Definition, Document, FragmentDefinition, OperationDefinition, Selection, SelectionSet,
9};
10
11/// Parse a GraphQL query source into a [`graphql_parser`] document.
12///
13/// Exposed so the server's HTTP handler can parse the request exactly once
14/// and reuse the AST for both complexity validation (via
15/// [`RequestValidator::validate_query_doc`]) and the downstream execution path
16/// — replacing the previous "validate then re-parse" pattern. See F001 in
17/// `IMPROVEMENTS.md`.
18///
19/// # Errors
20///
21/// Returns [`ComplexityValidationError::MalformedQuery`] when the source fails
22/// to parse as a GraphQL query document.
23pub fn parse_graphql_document(
24    query: &str,
25) -> Result<Document<'_, String>, ComplexityValidationError> {
26    if query.trim().is_empty() {
27        return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
28    }
29    graphql_parser::parse_query::<String>(query)
30        .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))
31}
32
33/// Default maximum number of aliases per query (alias amplification protection).
34///
35/// This constant is the single source of truth used by [`ComplexityConfig`],
36/// [`RequestValidator`], the server HTTP handler, and the CLI `explain` command.
37pub const DEFAULT_MAX_ALIASES: usize = 30;
38
39/// Maximum number of variables per request (`DoS` protection).
40///
41/// A single GraphQL request with thousands of variables can cause excessive memory
42/// allocation during deserialization and variable injection. This constant caps
43/// the number of top-level keys in the `variables` JSON object.
44pub const MAX_VARIABLES_COUNT: usize = 1_000;
45
46/// Configuration for query complexity limits.
47#[derive(Debug, Clone)]
48pub struct ComplexityConfig {
49    /// Maximum query depth (nesting level) — default: 10
50    pub max_depth:      usize,
51    /// Maximum complexity score — default: 100
52    pub max_complexity: usize,
53    /// Maximum number of field aliases per query — default: 30
54    pub max_aliases:    usize,
55}
56
57impl Default for ComplexityConfig {
58    fn default() -> Self {
59        Self {
60            max_depth:      10,
61            max_complexity: 100,
62            max_aliases:    DEFAULT_MAX_ALIASES,
63        }
64    }
65}
66
67/// Metrics returned by the AST-based analyzer.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct QueryMetrics {
70    /// Maximum selection-set nesting depth.
71    pub depth:       usize,
72    /// Total complexity score (accounts for pagination multipliers).
73    pub complexity:  usize,
74    /// Number of aliased fields in the document.
75    pub alias_count: usize,
76}
77
78/// GraphQL query validation error types (depth, complexity, aliases).
79#[derive(Debug, thiserror::Error, Clone)]
80#[non_exhaustive]
81pub enum ComplexityValidationError {
82    /// Query exceeds maximum allowed depth.
83    #[error("Query exceeds maximum depth of {max_depth}: depth = {actual_depth}")]
84    QueryTooDeep {
85        /// Maximum allowed depth
86        max_depth:    usize,
87        /// Actual query depth
88        actual_depth: usize,
89    },
90
91    /// Query exceeds maximum complexity score.
92    #[error("Query exceeds maximum complexity of {max_complexity}: score = {actual_complexity}")]
93    QueryTooComplex {
94        /// Maximum allowed complexity
95        max_complexity:    usize,
96        /// Actual query complexity
97        actual_complexity: usize,
98    },
99
100    /// Query contains too many aliases (alias amplification attack).
101    #[error("Query exceeds maximum alias count of {max_aliases}: count = {actual_aliases}")]
102    TooManyAliases {
103        /// Maximum allowed alias count
104        max_aliases:    usize,
105        /// Actual alias count
106        actual_aliases: usize,
107    },
108
109    /// Invalid query variables.
110    #[error("Invalid variables: {0}")]
111    InvalidVariables(String),
112
113    /// Malformed GraphQL query.
114    #[error("Malformed GraphQL query: {0}")]
115    MalformedQuery(String),
116}
117
118/// AST-based GraphQL request validator.
119///
120/// Uses `graphql-parser` to walk the full document tree. Correctly handles
121/// operation names, arguments, fragment spreads, inline fragments, and aliases —
122/// none of which a character-scan can distinguish from field names.
123#[derive(Debug, Clone)]
124pub struct RequestValidator {
125    /// Maximum query depth allowed.
126    max_depth:             usize,
127    /// Maximum query complexity score allowed.
128    max_complexity:        usize,
129    /// Maximum number of field aliases per query (alias amplification protection).
130    max_aliases_per_query: usize,
131    /// Enable query depth validation.
132    validate_depth:        bool,
133    /// Enable query complexity validation.
134    validate_complexity:   bool,
135}
136
137impl RequestValidator {
138    /// Create a new validator with default settings.
139    #[must_use]
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Create from a `ComplexityConfig`.
145    #[must_use]
146    pub const fn from_config(config: &ComplexityConfig) -> Self {
147        Self {
148            max_depth:             config.max_depth,
149            max_complexity:        config.max_complexity,
150            max_aliases_per_query: config.max_aliases,
151            validate_depth:        true,
152            validate_complexity:   true,
153        }
154    }
155
156    /// Set maximum query depth.
157    #[must_use]
158    pub const fn with_max_depth(mut self, max_depth: usize) -> Self {
159        self.max_depth = max_depth;
160        self
161    }
162
163    /// Set maximum query complexity.
164    #[must_use]
165    pub const fn with_max_complexity(mut self, max_complexity: usize) -> Self {
166        self.max_complexity = max_complexity;
167        self
168    }
169
170    /// Enable/disable depth validation.
171    #[must_use]
172    pub const fn with_depth_validation(mut self, enabled: bool) -> Self {
173        self.validate_depth = enabled;
174        self
175    }
176
177    /// Enable/disable complexity validation.
178    #[must_use]
179    pub const fn with_complexity_validation(mut self, enabled: bool) -> Self {
180        self.validate_complexity = enabled;
181        self
182    }
183
184    /// Set maximum number of aliases per query.
185    #[must_use]
186    pub const fn with_max_aliases(mut self, max_aliases: usize) -> Self {
187        self.max_aliases_per_query = max_aliases;
188        self
189    }
190
191    /// Compute query metrics without enforcing any limits.
192    ///
193    /// Returns [`QueryMetrics`] for the query.
194    /// Used by CLI tooling (`explain`, `cost` commands) where raw metrics
195    /// are needed without a hard rejection.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`ComplexityValidationError::MalformedQuery`] if the query cannot be parsed.
200    pub fn analyze(&self, query: &str) -> Result<QueryMetrics, ComplexityValidationError> {
201        if query.trim().is_empty() {
202            return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
203        }
204        let document = graphql_parser::parse_query::<String>(query)
205            .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
206        let fragments = collect_fragments(&document);
207        Ok(QueryMetrics {
208            depth:       self.calculate_depth_ast(&document, &fragments),
209            complexity:  self.calculate_complexity_ast(&document, &fragments),
210            alias_count: self.count_aliases_ast(&document),
211        })
212    }
213
214    /// Returns true when every configured validation knob is disabled and the
215    /// expensive AST parse can be skipped entirely.
216    ///
217    /// A validator is "fully disabled" only when depth, complexity, AND alias
218    /// checks are all off. The alias-amplification check is a distinct `DoS`
219    /// vector — `max_aliases_per_query == 0` disables it, any other value
220    /// keeps it active even when depth/complexity validation are turned off.
221    #[must_use]
222    pub const fn is_no_op(&self) -> bool {
223        !self.validate_depth && !self.validate_complexity && self.max_aliases_per_query == 0
224    }
225
226    /// Validate a GraphQL query string, enforcing configured limits.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`ComplexityValidationError`] if the query violates any validation rules.
231    pub fn validate_query(&self, query: &str) -> Result<(), ComplexityValidationError> {
232        if query.trim().is_empty() {
233            return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
234        }
235
236        if self.is_no_op() {
237            return Ok(());
238        }
239
240        let document = graphql_parser::parse_query::<String>(query)
241            .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
242        self.validate_query_doc(&document)
243    }
244
245    /// Validate an already-parsed GraphQL document, enforcing configured limits.
246    ///
247    /// This is the AST-only entry point used by the server's HTTP handler so the
248    /// document is parsed exactly once per request (rather than once by the
249    /// validator and once by the executor's matcher). See F001 in
250    /// `IMPROVEMENTS.md`.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`ComplexityValidationError`] if the document violates depth,
255    /// complexity, or alias-amplification limits.
256    pub fn validate_query_doc<'a>(
257        &self,
258        document: &'a Document<'a, String>,
259    ) -> Result<(), ComplexityValidationError> {
260        if self.is_no_op() {
261            return Ok(());
262        }
263
264        let fragments = collect_fragments(document);
265
266        if self.validate_depth {
267            let depth = self.calculate_depth_ast(document, &fragments);
268            if depth > self.max_depth {
269                return Err(ComplexityValidationError::QueryTooDeep {
270                    max_depth:    self.max_depth,
271                    actual_depth: depth,
272                });
273            }
274        }
275
276        if self.validate_complexity {
277            let complexity = self.calculate_complexity_ast(document, &fragments);
278            if complexity > self.max_complexity {
279                return Err(ComplexityValidationError::QueryTooComplex {
280                    max_complexity:    self.max_complexity,
281                    actual_complexity: complexity,
282                });
283            }
284        }
285
286        let alias_count = self.count_aliases_ast(document);
287        if alias_count > self.max_aliases_per_query {
288            return Err(ComplexityValidationError::TooManyAliases {
289                max_aliases:    self.max_aliases_per_query,
290                actual_aliases: alias_count,
291            });
292        }
293
294        Ok(())
295    }
296
297    /// Validate variables JSON.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`ComplexityValidationError`] if variables are not a JSON object or exceed
302    /// [`MAX_VARIABLES_COUNT`].
303    ///
304    /// # Panics
305    ///
306    /// Cannot panic in practice — the `expect` on `as_object()` is guarded
307    /// by a preceding `is_object()` check that returns `Err` first.
308    pub fn validate_variables(
309        &self,
310        variables: Option<&serde_json::Value>,
311    ) -> Result<(), ComplexityValidationError> {
312        if let Some(vars) = variables {
313            if !vars.is_object() {
314                return Err(ComplexityValidationError::InvalidVariables(
315                    "Variables must be an object".to_string(),
316                ));
317            }
318            // Reason: `is_object()` check on the line above guarantees this is an object;
319            // `as_object()` cannot return None here.
320            let obj = vars.as_object().expect("invariant: vars.is_object() checked above");
321            if obj.len() > MAX_VARIABLES_COUNT {
322                return Err(ComplexityValidationError::InvalidVariables(format!(
323                    "Too many variables: {} exceeds maximum of {}",
324                    obj.len(),
325                    MAX_VARIABLES_COUNT
326                )));
327            }
328        }
329        Ok(())
330    }
331
332    fn calculate_depth_ast(
333        &self,
334        document: &Document<String>,
335        fragments: &[&FragmentDefinition<String>],
336    ) -> usize {
337        document
338            .definitions
339            .iter()
340            .map(|def| match def {
341                Definition::Operation(op) => match op {
342                    OperationDefinition::Query(q) => {
343                        self.selection_set_depth(&q.selection_set, fragments, 0)
344                    },
345                    OperationDefinition::Mutation(m) => {
346                        self.selection_set_depth(&m.selection_set, fragments, 0)
347                    },
348                    OperationDefinition::Subscription(s) => {
349                        self.selection_set_depth(&s.selection_set, fragments, 0)
350                    },
351                    OperationDefinition::SelectionSet(ss) => {
352                        self.selection_set_depth(ss, fragments, 0)
353                    },
354                },
355                Definition::Fragment(f) => self.selection_set_depth(&f.selection_set, fragments, 0),
356            })
357            .max()
358            .unwrap_or(0)
359    }
360
361    fn selection_set_depth(
362        &self,
363        selection_set: &SelectionSet<String>,
364        fragments: &[&FragmentDefinition<String>],
365        recursion_depth: usize,
366    ) -> usize {
367        if recursion_depth > 32 {
368            return self.max_depth + 1;
369        }
370        if selection_set.items.is_empty() {
371            return 0;
372        }
373        let max_child = selection_set
374            .items
375            .iter()
376            .map(|sel| match sel {
377                Selection::Field(field) => {
378                    if field.selection_set.items.is_empty() {
379                        0
380                    } else {
381                        self.selection_set_depth(&field.selection_set, fragments, recursion_depth)
382                    }
383                },
384                Selection::InlineFragment(inline) => {
385                    self.selection_set_depth(&inline.selection_set, fragments, recursion_depth)
386                },
387                Selection::FragmentSpread(spread) => {
388                    if let Some(frag) = fragments.iter().find(|f| f.name == spread.fragment_name) {
389                        self.selection_set_depth(
390                            &frag.selection_set,
391                            fragments,
392                            recursion_depth + 1,
393                        )
394                    } else {
395                        self.max_depth
396                    }
397                },
398            })
399            .max()
400            .unwrap_or(0);
401        1 + max_child
402    }
403
404    fn calculate_complexity_ast(
405        &self,
406        document: &Document<String>,
407        fragments: &[&FragmentDefinition<String>],
408    ) -> usize {
409        document
410            .definitions
411            .iter()
412            .map(|def| match def {
413                Definition::Operation(op) => match op {
414                    OperationDefinition::Query(q) => {
415                        self.selection_set_complexity(&q.selection_set, fragments, 0)
416                    },
417                    OperationDefinition::Mutation(m) => {
418                        self.selection_set_complexity(&m.selection_set, fragments, 0)
419                    },
420                    OperationDefinition::Subscription(s) => {
421                        self.selection_set_complexity(&s.selection_set, fragments, 0)
422                    },
423                    OperationDefinition::SelectionSet(ss) => {
424                        self.selection_set_complexity(ss, fragments, 0)
425                    },
426                },
427                Definition::Fragment(_) => 0,
428            })
429            .fold(0usize, usize::saturating_add)
430    }
431
432    fn selection_set_complexity(
433        &self,
434        selection_set: &SelectionSet<String>,
435        fragments: &[&FragmentDefinition<String>],
436        recursion_depth: usize,
437    ) -> usize {
438        if recursion_depth > 32 {
439            return self.max_complexity + 1;
440        }
441        selection_set
442            .items
443            .iter()
444            .map(|sel| match sel {
445                Selection::Field(field) => {
446                    let multiplier = extract_limit_multiplier(&field.arguments);
447                    if field.selection_set.items.is_empty() {
448                        1
449                    } else {
450                        let nested = self.selection_set_complexity(
451                            &field.selection_set,
452                            fragments,
453                            recursion_depth,
454                        );
455                        // Saturating: the multiplier compounds per nesting level,
456                        // so a crafted deep query overflows `usize`. Saturating to
457                        // `usize::MAX` keeps the score monotonic and fail-closed
458                        // (the limit always rejects it) instead of wrapping under
459                        // the limit (release) or panicking (overflow-checked builds).
460                        1usize.saturating_add(nested.saturating_mul(multiplier))
461                    }
462                },
463                Selection::InlineFragment(inline) => {
464                    self.selection_set_complexity(&inline.selection_set, fragments, recursion_depth)
465                },
466                Selection::FragmentSpread(spread) => {
467                    if let Some(frag) = fragments.iter().find(|f| f.name == spread.fragment_name) {
468                        self.selection_set_complexity(
469                            &frag.selection_set,
470                            fragments,
471                            recursion_depth + 1,
472                        )
473                    } else {
474                        10
475                    }
476                },
477            })
478            .fold(0usize, usize::saturating_add)
479    }
480
481    fn count_aliases_ast(&self, document: &Document<String>) -> usize {
482        document
483            .definitions
484            .iter()
485            .map(|def| match def {
486                Definition::Operation(op) => {
487                    let ss = match op {
488                        OperationDefinition::Query(q) => &q.selection_set,
489                        OperationDefinition::Mutation(m) => &m.selection_set,
490                        OperationDefinition::Subscription(s) => &s.selection_set,
491                        OperationDefinition::SelectionSet(ss) => ss,
492                    };
493                    count_aliases_in_selection_set(ss)
494                },
495                Definition::Fragment(f) => count_aliases_in_selection_set(&f.selection_set),
496            })
497            .sum()
498    }
499}
500
501impl Default for RequestValidator {
502    fn default() -> Self {
503        Self {
504            max_depth:             10,
505            max_complexity:        100,
506            max_aliases_per_query: DEFAULT_MAX_ALIASES,
507            validate_depth:        true,
508            validate_complexity:   true,
509        }
510    }
511}
512
513/// Collect all fragment definitions from a parsed document.
514fn collect_fragments<'a>(
515    document: &'a Document<'a, String>,
516) -> Vec<&'a FragmentDefinition<'a, String>> {
517    document
518        .definitions
519        .iter()
520        .filter_map(|def| {
521            if let Definition::Fragment(f) = def {
522                Some(f)
523            } else {
524                None
525            }
526        })
527        .collect()
528}
529
530/// Extract pagination limit from field arguments to use as a cost multiplier.
531fn extract_limit_multiplier(arguments: &[(String, graphql_parser::query::Value<String>)]) -> usize {
532    for (name, value) in arguments {
533        if matches!(name.as_str(), "first" | "limit" | "take" | "last") {
534            if let graphql_parser::query::Value::Int(n) = value {
535                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
536                // Reason: value is clamped to [1, 100] immediately after; truncation and sign loss
537                // are safe
538                let limit = n.as_i64().unwrap_or(10) as usize;
539                return limit.clamp(1, 100);
540            }
541        }
542    }
543    1
544}
545
546/// Recursively count aliases in a selection set.
547fn count_aliases_in_selection_set(selection_set: &SelectionSet<String>) -> usize {
548    selection_set
549        .items
550        .iter()
551        .map(|sel| match sel {
552            Selection::Field(field) => {
553                let self_alias = usize::from(field.alias.is_some());
554                self_alias + count_aliases_in_selection_set(&field.selection_set)
555            },
556            Selection::InlineFragment(inline) => {
557                count_aliases_in_selection_set(&inline.selection_set)
558            },
559            Selection::FragmentSpread(_) => 0,
560        })
561        .sum()
562}