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 std::collections::{HashMap, HashSet};
8
9use graphql_parser::query::{
10 Definition, Document, Field, FragmentDefinition, OperationDefinition, Selection, SelectionSet,
11};
12
13/// Parse a GraphQL query source into a [`graphql_parser`] document.
14///
15/// Exposed so the server's HTTP handler can parse the request exactly once
16/// and reuse the AST for both complexity validation (via
17/// [`RequestValidator::validate_query_doc`]) and the downstream execution path
18/// — replacing the previous "validate then re-parse" pattern. See F001 in
19/// `IMPROVEMENTS.md`.
20///
21/// # Errors
22///
23/// Returns [`ComplexityValidationError::MalformedQuery`] when the source fails
24/// to parse as a GraphQL query document.
25pub fn parse_graphql_document(
26 query: &str,
27) -> Result<Document<'_, String>, ComplexityValidationError> {
28 if query.trim().is_empty() {
29 return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
30 }
31 graphql_parser::parse_query::<String>(query)
32 .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))
33}
34
35/// Default maximum number of aliases per query (alias amplification protection).
36///
37/// This constant is the single source of truth used by [`ComplexityConfig`],
38/// [`RequestValidator`], the server HTTP handler, and the CLI `explain` command.
39pub const DEFAULT_MAX_ALIASES: usize = 30;
40
41/// Maximum number of variables per request (`DoS` protection).
42///
43/// A single GraphQL request with thousands of variables can cause excessive memory
44/// allocation during deserialization and variable injection. This constant caps
45/// the number of top-level keys in the `variables` JSON object.
46pub const MAX_VARIABLES_COUNT: usize = 1_000;
47
48/// Configuration for query complexity limits.
49#[derive(Debug, Clone)]
50pub struct ComplexityConfig {
51 /// Maximum query depth (nesting level) — default: 10
52 pub max_depth: usize,
53 /// Maximum complexity score — default: 100
54 pub max_complexity: usize,
55 /// Maximum number of field aliases per query — default: 30
56 pub max_aliases: usize,
57}
58
59impl Default for ComplexityConfig {
60 fn default() -> Self {
61 Self {
62 max_depth: 10,
63 max_complexity: 100,
64 max_aliases: DEFAULT_MAX_ALIASES,
65 }
66 }
67}
68
69/// Metrics returned by the AST-based analyzer.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct QueryMetrics {
72 /// Maximum selection-set nesting depth.
73 pub depth: usize,
74 /// Total complexity score (accounts for pagination multipliers).
75 pub complexity: usize,
76 /// Number of aliased fields in the document.
77 pub alias_count: usize,
78}
79
80/// GraphQL query validation error types (depth, complexity, aliases).
81#[derive(Debug, thiserror::Error, Clone)]
82#[non_exhaustive]
83pub enum ComplexityValidationError {
84 /// Query exceeds maximum allowed depth.
85 #[error("Query exceeds maximum depth of {max_depth}: depth = {actual_depth}")]
86 QueryTooDeep {
87 /// Maximum allowed depth
88 max_depth: usize,
89 /// Actual query depth
90 actual_depth: usize,
91 },
92
93 /// Query exceeds maximum complexity score.
94 #[error("Query exceeds maximum complexity of {max_complexity}: score = {actual_complexity}")]
95 QueryTooComplex {
96 /// Maximum allowed complexity
97 max_complexity: usize,
98 /// Actual query complexity
99 actual_complexity: usize,
100 },
101
102 /// Query contains too many aliases (alias amplification attack).
103 #[error("Query exceeds maximum alias count of {max_aliases}: count = {actual_aliases}")]
104 TooManyAliases {
105 /// Maximum allowed alias count
106 max_aliases: usize,
107 /// Actual alias count
108 actual_aliases: usize,
109 },
110
111 /// Invalid query variables.
112 #[error("Invalid variables: {0}")]
113 InvalidVariables(String),
114
115 /// Malformed GraphQL query.
116 #[error("Malformed GraphQL query: {0}")]
117 MalformedQuery(String),
118}
119
120/// AST-based GraphQL request validator.
121///
122/// Uses `graphql-parser` to walk the full document tree. Correctly handles
123/// operation names, arguments, fragment spreads, inline fragments, and aliases —
124/// none of which a character-scan can distinguish from field names.
125#[derive(Debug, Clone)]
126pub struct RequestValidator {
127 /// Maximum query depth allowed.
128 max_depth: usize,
129 /// Maximum query complexity score allowed.
130 max_complexity: usize,
131 /// Maximum number of field aliases per query (alias amplification protection).
132 max_aliases_per_query: usize,
133 /// Enable query depth validation.
134 validate_depth: bool,
135 /// Enable query complexity validation.
136 validate_complexity: bool,
137}
138
139impl RequestValidator {
140 /// Create a new validator with default settings.
141 #[must_use]
142 pub fn new() -> Self {
143 Self::default()
144 }
145
146 /// Create from a `ComplexityConfig`.
147 #[must_use]
148 pub const fn from_config(config: &ComplexityConfig) -> Self {
149 Self {
150 max_depth: config.max_depth,
151 max_complexity: config.max_complexity,
152 max_aliases_per_query: config.max_aliases,
153 validate_depth: true,
154 validate_complexity: true,
155 }
156 }
157
158 /// Set maximum query depth.
159 #[must_use]
160 pub const fn with_max_depth(mut self, max_depth: usize) -> Self {
161 self.max_depth = max_depth;
162 self
163 }
164
165 /// Set maximum query complexity.
166 #[must_use]
167 pub const fn with_max_complexity(mut self, max_complexity: usize) -> Self {
168 self.max_complexity = max_complexity;
169 self
170 }
171
172 /// Enable/disable depth validation.
173 #[must_use]
174 pub const fn with_depth_validation(mut self, enabled: bool) -> Self {
175 self.validate_depth = enabled;
176 self
177 }
178
179 /// Enable/disable complexity validation.
180 #[must_use]
181 pub const fn with_complexity_validation(mut self, enabled: bool) -> Self {
182 self.validate_complexity = enabled;
183 self
184 }
185
186 /// Set maximum number of aliases per query.
187 #[must_use]
188 pub const fn with_max_aliases(mut self, max_aliases: usize) -> Self {
189 self.max_aliases_per_query = max_aliases;
190 self
191 }
192
193 /// Compute query metrics without enforcing any limits.
194 ///
195 /// Returns [`QueryMetrics`] for the query.
196 /// Used by CLI tooling (`explain`, `cost` commands) where raw metrics
197 /// are needed without a hard rejection.
198 ///
199 /// # Errors
200 ///
201 /// Returns [`ComplexityValidationError::MalformedQuery`] if the query cannot be parsed.
202 pub fn analyze(&self, query: &str) -> Result<QueryMetrics, ComplexityValidationError> {
203 if query.trim().is_empty() {
204 return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
205 }
206 let document = graphql_parser::parse_query::<String>(query)
207 .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
208 Ok(self.document_metrics(&document))
209 }
210
211 /// Compute depth, complexity, and alias count in a single memoizing pass.
212 ///
213 /// Resolving each fragment's contribution exactly once (and rejecting
214 /// fragment cycles) keeps this linear in document size regardless of
215 /// fragment-spread topology — without it, B-way branching across D chained
216 /// spreads costs B^D walks and the validation step itself becomes the `DoS`
217 /// (audit H4).
218 fn document_metrics<'a>(&self, document: &'a Document<'a, String>) -> QueryMetrics {
219 let fragments = collect_fragments(document);
220 let mut analyzer = DocumentAnalyzer::new(&fragments, self.max_depth, self.max_complexity);
221 analyzer.analyze_document(document)
222 }
223
224 /// Returns true when every configured validation knob is disabled and the
225 /// expensive AST parse can be skipped entirely.
226 ///
227 /// A validator is "fully disabled" only when depth, complexity, AND alias
228 /// checks are all off. The alias-amplification check is a distinct `DoS`
229 /// vector — `max_aliases_per_query == 0` disables it, any other value
230 /// keeps it active even when depth/complexity validation are turned off.
231 #[must_use]
232 pub const fn is_no_op(&self) -> bool {
233 !self.validate_depth && !self.validate_complexity && self.max_aliases_per_query == 0
234 }
235
236 /// Validate a GraphQL query string, enforcing configured limits.
237 ///
238 /// # Errors
239 ///
240 /// Returns [`ComplexityValidationError`] if the query violates any validation rules.
241 pub fn validate_query(&self, query: &str) -> Result<(), ComplexityValidationError> {
242 if query.trim().is_empty() {
243 return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
244 }
245
246 if self.is_no_op() {
247 return Ok(());
248 }
249
250 let document = graphql_parser::parse_query::<String>(query)
251 .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
252 self.validate_query_doc(&document)
253 }
254
255 /// Validate an already-parsed GraphQL document, enforcing configured limits.
256 ///
257 /// This is the AST-only entry point used by the server's HTTP handler so the
258 /// document is parsed exactly once per request (rather than once by the
259 /// validator and once by the executor's matcher). See F001 in
260 /// `IMPROVEMENTS.md`.
261 ///
262 /// # Errors
263 ///
264 /// Returns [`ComplexityValidationError`] if the document violates depth,
265 /// complexity, or alias-amplification limits.
266 pub fn validate_query_doc<'a>(
267 &self,
268 document: &'a Document<'a, String>,
269 ) -> Result<(), ComplexityValidationError> {
270 if self.is_no_op() {
271 return Ok(());
272 }
273
274 let metrics = self.document_metrics(document);
275
276 if self.validate_depth && metrics.depth > self.max_depth {
277 return Err(ComplexityValidationError::QueryTooDeep {
278 max_depth: self.max_depth,
279 actual_depth: metrics.depth,
280 });
281 }
282
283 if self.validate_complexity && metrics.complexity > self.max_complexity {
284 return Err(ComplexityValidationError::QueryTooComplex {
285 max_complexity: self.max_complexity,
286 actual_complexity: metrics.complexity,
287 });
288 }
289
290 if metrics.alias_count > self.max_aliases_per_query {
291 return Err(ComplexityValidationError::TooManyAliases {
292 max_aliases: self.max_aliases_per_query,
293 actual_aliases: metrics.alias_count,
294 });
295 }
296
297 Ok(())
298 }
299
300 /// Validate variables JSON.
301 ///
302 /// # Errors
303 ///
304 /// Returns [`ComplexityValidationError`] if variables are not a JSON object or exceed
305 /// [`MAX_VARIABLES_COUNT`].
306 ///
307 /// # Panics
308 ///
309 /// Cannot panic in practice — the `expect` on `as_object()` is guarded
310 /// by a preceding `is_object()` check that returns `Err` first.
311 pub fn validate_variables(
312 &self,
313 variables: Option<&serde_json::Value>,
314 ) -> Result<(), ComplexityValidationError> {
315 if let Some(vars) = variables {
316 if !vars.is_object() {
317 return Err(ComplexityValidationError::InvalidVariables(
318 "Variables must be an object".to_string(),
319 ));
320 }
321 // Reason: `is_object()` check on the line above guarantees this is an object;
322 // `as_object()` cannot return None here.
323 let obj = vars.as_object().expect("invariant: vars.is_object() checked above");
324 if obj.len() > MAX_VARIABLES_COUNT {
325 return Err(ComplexityValidationError::InvalidVariables(format!(
326 "Too many variables: {} exceeds maximum of {}",
327 obj.len(),
328 MAX_VARIABLES_COUNT
329 )));
330 }
331 }
332 Ok(())
333 }
334}
335
336/// Hard ceiling on fragment-spread chain length the analyzer resolves before
337/// declaring the document over-limit. Mirrors the pre-memoization 32-hop
338/// recursion guard: a document nesting fragment spreads beyond this is rejected
339/// (real APIs never approach it), which also bounds the analyzer's own
340/// recursion depth so a long fragment chain cannot overflow the stack. Fragment
341/// cycles are caught earlier by the [`DocumentAnalyzer`] `visiting` set.
342const FRAGMENT_SPREAD_DEPTH_LIMIT: usize = 32;
343
344/// Depth, complexity, and alias contribution of one selection set (or one
345/// fragment). Computed once per fragment and memoized by name so a fragment
346/// spread costs a map lookup instead of a re-walk.
347#[derive(Debug, Clone, Copy)]
348struct SelectionMetrics {
349 depth: usize,
350 complexity: usize,
351 aliases: usize,
352}
353
354/// Single-pass, memoizing analyzer shared by depth, complexity, and alias
355/// counting.
356///
357/// Each fragment's metrics are resolved exactly once (on demand) and cached by
358/// name; fragment cycles are detected via `visiting` and treated as over-limit
359/// rather than recursed into. This is what bounds the validator's own cost —
360/// the previous per-spread re-walk made the validation step itself a `DoS`:
361/// B-way branching across D chained fragment spreads cost B^D walks (audit H4),
362/// and aliases hidden inside spread fragments were never counted, bypassing the
363/// alias-amplification limit (audit H4, alias counter).
364struct DocumentAnalyzer<'a> {
365 by_name: HashMap<&'a str, &'a FragmentDefinition<'a, String>>,
366 memo: HashMap<&'a str, SelectionMetrics>,
367 visiting: HashSet<&'a str>,
368 max_depth: usize,
369 max_complexity: usize,
370}
371
372impl<'a> DocumentAnalyzer<'a> {
373 fn new(
374 fragments: &[&'a FragmentDefinition<'a, String>],
375 max_depth: usize,
376 max_complexity: usize,
377 ) -> Self {
378 let mut by_name = HashMap::with_capacity(fragments.len());
379 for frag in fragments {
380 // First definition wins, matching the previous `.find()` lookup.
381 by_name.entry(frag.name.as_str()).or_insert(*frag);
382 }
383 Self {
384 by_name,
385 memo: HashMap::new(),
386 visiting: HashSet::new(),
387 max_depth,
388 max_complexity,
389 }
390 }
391
392 /// Metrics that force rejection on both the depth and complexity gates,
393 /// used for fragment cycles and over-long spread chains (never executable).
394 const fn over_limit(&self) -> SelectionMetrics {
395 SelectionMetrics {
396 depth: self.max_depth.saturating_add(1),
397 complexity: self.max_complexity.saturating_add(1),
398 aliases: 0,
399 }
400 }
401
402 /// Reduce the whole document to a single [`QueryMetrics`].
403 ///
404 /// Depth is the max across every definition (operations *and* fragment
405 /// definitions, preserving legacy behaviour); complexity sums operations
406 /// only; the alias total sums operations only, where each fragment spread
407 /// contributes the fragment's own alias count *per occurrence* — that is
408 /// the alias-amplification fix (the old counter scored spreads as 0).
409 fn analyze_document(&mut self, document: &'a Document<'a, String>) -> QueryMetrics {
410 let mut depth = 0usize;
411 let mut complexity = 0usize;
412 let mut aliases = 0usize;
413 for def in &document.definitions {
414 match def {
415 Definition::Operation(op) => {
416 let m = self.selection_metrics(
417 operation_selection_set(op),
418 FRAGMENT_SPREAD_DEPTH_LIMIT,
419 );
420 depth = depth.max(m.depth);
421 complexity = complexity.saturating_add(m.complexity);
422 aliases = aliases.saturating_add(m.aliases);
423 },
424 Definition::Fragment(f) => {
425 let m = self.resolve_fragment(f.name.as_str(), FRAGMENT_SPREAD_DEPTH_LIMIT);
426 depth = depth.max(m.depth);
427 },
428 }
429 }
430 QueryMetrics {
431 depth,
432 complexity,
433 alias_count: aliases,
434 }
435 }
436
437 /// Memoized metrics for a fragment by name.
438 ///
439 /// `budget` is the remaining fragment-spread hops; it decrements on each
440 /// spread resolution and a cycle (`visiting`) or exhausted budget yields the
441 /// over-limit sentinel without recursing. Cut/cycle results are deliberately
442 /// not memoized — they are context-dependent, not the fragment's intrinsic
443 /// metric.
444 fn resolve_fragment(&mut self, name: &'a str, budget: usize) -> SelectionMetrics {
445 if let Some(m) = self.memo.get(name) {
446 return *m;
447 }
448 if self.visiting.contains(name) || budget == 0 {
449 return self.over_limit();
450 }
451 let Some(frag) = self.by_name.get(name).copied() else {
452 // Unknown fragment: preserve the legacy contribution — depth =
453 // max_depth (so the enclosing selection set trips the depth gate),
454 // complexity = 10, no aliases.
455 let m = SelectionMetrics {
456 depth: self.max_depth,
457 complexity: 10,
458 aliases: 0,
459 };
460 self.memo.insert(name, m);
461 return m;
462 };
463 self.visiting.insert(name);
464 let m = self.selection_metrics(&frag.selection_set, budget - 1);
465 self.visiting.remove(name);
466 self.memo.insert(name, m);
467 m
468 }
469
470 fn selection_metrics(
471 &mut self,
472 selection_set: &'a SelectionSet<'a, String>,
473 budget: usize,
474 ) -> SelectionMetrics {
475 let mut max_child_depth = 0usize;
476 let mut complexity = 0usize;
477 let mut aliases = 0usize;
478 for sel in &selection_set.items {
479 let child = match sel {
480 Selection::Field(field) => {
481 let self_alias = usize::from(field.alias.is_some());
482 if field.selection_set.items.is_empty() {
483 SelectionMetrics {
484 depth: 0,
485 complexity: 1,
486 aliases: self_alias,
487 }
488 } else {
489 let nested = self.selection_metrics(&field.selection_set, budget);
490 let multiplier = extract_limit_multiplier(&field.arguments);
491 SelectionMetrics {
492 depth: nested.depth,
493 // Saturating: the multiplier compounds per nesting
494 // level, so a crafted deep query overflows `usize`.
495 // Saturating to `usize::MAX` keeps the score
496 // monotonic and fail-closed (the limit always
497 // rejects it) rather than wrapping under the limit.
498 complexity: 1usize
499 .saturating_add(nested.complexity.saturating_mul(multiplier)),
500 aliases: self_alias.saturating_add(nested.aliases),
501 }
502 }
503 },
504 Selection::InlineFragment(inline) => {
505 self.selection_metrics(&inline.selection_set, budget)
506 },
507 Selection::FragmentSpread(spread) => {
508 self.resolve_fragment(spread.fragment_name.as_str(), budget)
509 },
510 };
511 max_child_depth = max_child_depth.max(child.depth);
512 complexity = complexity.saturating_add(child.complexity);
513 aliases = aliases.saturating_add(child.aliases);
514 }
515 let depth = if selection_set.items.is_empty() {
516 0
517 } else {
518 max_child_depth.saturating_add(1)
519 };
520 SelectionMetrics {
521 depth,
522 complexity,
523 aliases,
524 }
525 }
526
527 /// Complexity contribution of a single field — `1` for a leaf, else
528 /// `1 + nested_complexity * pagination_multiplier`. This is exactly the
529 /// per-field score [`selection_metrics`](Self::selection_metrics) assigns, so
530 /// a cost walk with no `@cost` overrides equals the complexity score.
531 fn field_complexity(&mut self, field: &'a Field<'a, String>, budget: usize) -> usize {
532 if field.selection_set.items.is_empty() {
533 1
534 } else {
535 let nested = self.selection_metrics(&field.selection_set, budget);
536 let multiplier = extract_limit_multiplier(&field.arguments);
537 1usize.saturating_add(nested.complexity.saturating_mul(multiplier))
538 }
539 }
540
541 /// Document cost for per-tenant budget enforcement.
542 ///
543 /// Equals the complexity score, except a top-level (root) operation field
544 /// whose name carries an `@cost` weight contributes exactly that weight (its
545 /// subtree is not walked). Root inline fragments/spreads fall back to the
546 /// normal complexity contribution.
547 fn document_cost<S: std::hash::BuildHasher>(
548 &mut self,
549 document: &'a Document<'a, String>,
550 root_weights: &HashMap<String, usize, S>,
551 ) -> usize {
552 let mut cost = 0usize;
553 for def in &document.definitions {
554 if let Definition::Operation(op) = def {
555 cost =
556 cost.saturating_add(self.root_cost(operation_selection_set(op), root_weights));
557 }
558 }
559 cost
560 }
561
562 fn root_cost<S: std::hash::BuildHasher>(
563 &mut self,
564 selection_set: &'a SelectionSet<'a, String>,
565 root_weights: &HashMap<String, usize, S>,
566 ) -> usize {
567 let mut cost = 0usize;
568 for sel in &selection_set.items {
569 let c = match sel {
570 Selection::Field(field) => root_weights
571 .get(field.name.as_str())
572 .copied()
573 .unwrap_or_else(|| self.field_complexity(field, FRAGMENT_SPREAD_DEPTH_LIMIT)),
574 Selection::InlineFragment(inline) => {
575 self.root_cost(&inline.selection_set, root_weights)
576 },
577 Selection::FragmentSpread(spread) => {
578 self.resolve_fragment(
579 spread.fragment_name.as_str(),
580 FRAGMENT_SPREAD_DEPTH_LIMIT,
581 )
582 .complexity
583 },
584 };
585 cost = cost.saturating_add(c);
586 }
587 cost
588 }
589}
590
591/// Estimate a parsed document's cost for per-tenant budget enforcement.
592///
593/// The cost equals the AST complexity score (so it is consistent with the
594/// `max_complexity` gate), except a top-level operation field whose name appears
595/// in `root_cost_weights` contributes exactly that `@cost` weight instead of its
596/// walked subtree. With an empty map the result equals
597/// [`RequestValidator::analyze`]'s `complexity`.
598#[must_use]
599pub fn estimate_query_cost<'a, S: std::hash::BuildHasher>(
600 document: &'a Document<'a, String>,
601 root_cost_weights: &HashMap<String, usize, S>,
602) -> usize {
603 let fragments = collect_fragments(document);
604 // usize::MAX sentinels so a fragment cycle yields a saturating (rejected) cost
605 // rather than an artificial cap.
606 let mut analyzer = DocumentAnalyzer::new(&fragments, usize::MAX, usize::MAX);
607 analyzer.document_cost(document, root_cost_weights)
608}
609
610/// The top-level selection set of any operation kind.
611const fn operation_selection_set<'a>(
612 op: &'a OperationDefinition<'a, String>,
613) -> &'a SelectionSet<'a, String> {
614 match op {
615 OperationDefinition::Query(q) => &q.selection_set,
616 OperationDefinition::Mutation(m) => &m.selection_set,
617 OperationDefinition::Subscription(s) => &s.selection_set,
618 OperationDefinition::SelectionSet(ss) => ss,
619 }
620}
621
622impl Default for RequestValidator {
623 fn default() -> Self {
624 Self {
625 max_depth: 10,
626 max_complexity: 100,
627 max_aliases_per_query: DEFAULT_MAX_ALIASES,
628 validate_depth: true,
629 validate_complexity: true,
630 }
631 }
632}
633
634/// Collect all fragment definitions from a parsed document.
635fn collect_fragments<'a>(
636 document: &'a Document<'a, String>,
637) -> Vec<&'a FragmentDefinition<'a, String>> {
638 document
639 .definitions
640 .iter()
641 .filter_map(|def| {
642 if let Definition::Fragment(f) = def {
643 Some(f)
644 } else {
645 None
646 }
647 })
648 .collect()
649}
650
651/// Extract pagination limit from field arguments to use as a cost multiplier.
652fn extract_limit_multiplier(arguments: &[(String, graphql_parser::query::Value<String>)]) -> usize {
653 for (name, value) in arguments {
654 if matches!(name.as_str(), "first" | "limit" | "take" | "last") {
655 if let graphql_parser::query::Value::Int(n) = value {
656 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
657 // Reason: value is clamped to [1, 100] immediately after; truncation and sign loss
658 // are safe
659 let limit = n.as_i64().unwrap_or(10) as usize;
660 return limit.clamp(1, 100);
661 }
662 }
663 }
664 1
665}