Skip to main content

waf_detection/
graphql.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! GraphQL structural protections (Phase 11).
5//!
6//! This is NOT content-inspection (injection inside arguments/variables is already
7//! caught by the JSON-leaf / derived channel, §6) — it is a STRUCTURAL control like
8//! `request_smuggling`: it enforces DoS/abuse caps on the SHAPE of the GraphQL
9//! operation(s) a request carries — selection-set depth, alias/field/directive
10//! counts, batch size — plus an optional introspection policy. The counts come from
11//! the lexical [`graphql_lex`] pass (paren-aware depth, string/comment-skipping), so
12//! the module does NOT join the content-rule prefilter union.
13//!
14//! Transports recognised (Phase-11 Step-0 probe): a JSON body `query` leaf (and the
15//! `<i>.query` leaves of a batched array) and a GET `?query=` value — both ONLY on a
16//! configured GraphQL path, so a non-GraphQL JSON API with a `query` field is left
17//! alone — and an `application/graphql` raw body, recognised by Content-Type on any
18//! path. Violations of the DoS caps → `Reject{400}`; blocked introspection → 403.
19
20use waf_core::{Config, Decision, GraphqlConfig, ParsedBody, Phase, RequestContext, WafModule};
21use waf_normalizer::graphql::{graphql_lex, unwrap_query_envelope};
22
23#[derive(Default)]
24pub struct GraphqlModule {
25    cfg: GraphqlConfig,
26}
27
28impl GraphqlModule {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// The GraphQL operation text(s) the request carries (empty = not a recognised
34    /// GraphQL request → the module stays out of the way).
35    fn operations(&self, ctx: &RequestContext) -> Vec<String> {
36        // Raw carrier strings before envelope-unwrapping (see `expand`).
37        let mut carriers = Vec::new();
38
39        // `application/graphql` raw body — identified by Content-Type, any path. A
40        // single operation (this media type does not batch).
41        if content_type(ctx).trim_start().starts_with("application/graphql") {
42            if let ParsedBody::Raw(b) = &ctx.normalized.body {
43                if let Ok(s) = std::str::from_utf8(b) {
44                    carriers.push(s.to_string());
45                }
46            }
47            return expand(carriers);
48        }
49
50        // JSON / GET transports: only on a configured GraphQL endpoint path.
51        let path_match = self
52            .cfg
53            .paths
54            .iter()
55            .any(|p| ctx.normalized.path.eq_ignore_ascii_case(p));
56        if !path_match {
57            return Vec::new();
58        }
59
60        if let ParsedBody::JsonFlattened(pairs) = &ctx.normalized.body {
61            for (k, v) in pairs {
62                if is_graphql_query_key(k) {
63                    carriers.push(v.clone());
64                }
65            }
66        }
67        if ctx.method.eq_ignore_ascii_case("GET") {
68            for (k, v) in &ctx.normalized.query_params {
69                if k == "query" {
70                    carriers.push(v.clone());
71                }
72            }
73        }
74        expand(carriers)
75    }
76}
77
78/// Expand each carrier into the operation(s) the lexer must see: a carrier that is a JSON
79/// envelope `{"query":"<doc>"}` (gotestwaf's GET shape — the lexer would otherwise treat
80/// the document as opaque string content) is unwrapped to the inner document(s); any other
81/// carrier (a bare GraphQL document, `application/graphql` body, or already-extracted JSON
82/// leaf) is lexed as-is.
83fn expand(carriers: Vec<String>) -> Vec<String> {
84    let mut out = Vec::with_capacity(carriers.len());
85    for c in carriers {
86        match unwrap_query_envelope(&c) {
87            Some(docs) => out.extend(docs),
88            None => out.push(c),
89        }
90    }
91    out
92}
93
94fn content_type(ctx: &RequestContext) -> &str {
95    ctx.normalized
96        .headers
97        .iter()
98        .find(|(k, _)| k == "content-type")
99        .map(|(_, v)| v.as_str())
100        .unwrap_or("")
101}
102
103/// A flattened-JSON key that holds a GraphQL operation: the top-level `query`, or a
104/// batch element `<index>.query`. NOT an arbitrary `*.query` (e.g. a variable named
105/// `query` nested under `variables`) — those must not be parsed as operations.
106fn is_graphql_query_key(k: &str) -> bool {
107    if k == "query" {
108        return true;
109    }
110    match k.strip_suffix(".query") {
111        Some(prefix) => !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()),
112        None => false,
113    }
114}
115
116fn reject(reason: &'static str) -> Decision {
117    Decision::Reject {
118        rule_id: "graphql".to_string(),
119        reason: reason.to_string(),
120        status: 400,
121        retry_after: None,
122    }
123}
124
125impl WafModule for GraphqlModule {
126    fn id(&self) -> &str {
127        "graphql"
128    }
129
130    fn phase(&self) -> Phase {
131        Phase::Body
132    }
133
134    /// Structural: its caps are not content-rule matches, so the content fast-path
135    /// must not skip it (else a GraphQL DoS with no content signature would bypass).
136    fn structural(&self) -> bool {
137        true
138    }
139
140    fn init(&mut self, cfg: &Config) {
141        self.cfg = cfg.modules.graphql.clone();
142    }
143
144    fn inspect(&self, ctx: &RequestContext) -> Decision {
145        if !self.cfg.enabled {
146            return Decision::Allow;
147        }
148        let ops = self.operations(ctx);
149        if ops.is_empty() {
150            return Decision::Allow;
151        }
152        // Batch amplification (also bypasses per-request rate limits).
153        if ops.len() as u32 > self.cfg.max_batch {
154            return reject("graphql batch operation count exceeds limit");
155        }
156        for op in &ops {
157            let st = graphql_lex(op);
158            if st.max_depth > self.cfg.max_depth {
159                return reject("graphql query nesting depth exceeds limit");
160            }
161            if st.aliases > self.cfg.max_aliases {
162                return reject("graphql alias count exceeds limit");
163            }
164            if st.fields > self.cfg.max_fields {
165                return reject("graphql field/complexity exceeds limit");
166            }
167            if st.directives > self.cfg.max_directives {
168                return reject("graphql directive count exceeds limit");
169            }
170            if self.cfg.block_introspection && st.has_introspection {
171                return Decision::Block {
172                    rule_id: "graphql-introspection".to_string(),
173                    reason: "graphql schema introspection is blocked".to_string(),
174                };
175            }
176        }
177        Decision::Allow
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::is_graphql_query_key;
184
185    #[test]
186    fn query_key_matching() {
187        // Operation-carrying keys.
188        assert!(is_graphql_query_key("query")); // single operation
189        assert!(is_graphql_query_key("0.query")); // batch element
190        assert!(is_graphql_query_key("11.query"));
191        // NOT operations: a variable/field named `query` nested elsewhere must be ignored.
192        assert!(!is_graphql_query_key("variables.query"));
193        assert!(!is_graphql_query_key("variables.filter.query"));
194        assert!(!is_graphql_query_key("a.query"));
195        assert!(!is_graphql_query_key("query.sub"));
196        assert!(!is_graphql_query_key("operationName"));
197        assert!(!is_graphql_query_key(".query"));
198    }
199}