Skip to main content

waf_normalizer/
graphql.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Minimal GraphQL lexer (Phase 11) — the 8th custom parser in this crate (fuzzed,
5//! see ARCHITECTURE §13). It does NOT build an AST: it makes a single linear,
6//! UTF-8-safe, allocation-free pass over the query text and computes the structural
7//! metrics the `graphql` detection module enforces (depth / aliases / fields /
8//! directives / introspection). It is evasion-robust at the LEXICAL level — braces,
9//! colons and names inside strings, block strings (`"""…"""`) and `#` comments are
10//! skipped, so they cannot inflate or deflate the counts.
11//!
12//! **Paren-aware depth.** `{ }` delimits a *selection set* (which is what "depth"
13//! means for a GraphQL DoS) but ALSO an *input object* inside an argument list
14//! (`field(arg: { … })`). To count only selection-set nesting, a `{` increments the
15//! depth ONLY while not inside `(` … `)`. So a query with a deeply nested input
16//! object but a flat selection set reports a *small* depth (no false positive).
17
18/// Structural metrics of one GraphQL query/operation text.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub struct GraphqlStats {
21    /// Maximum selection-set nesting depth (paren-aware: input-object braces excluded).
22    pub max_depth: u32,
23    /// Alias separators (`alias: field`) — a `:` in selection-set context. The
24    /// "alias bomb" DoS signal.
25    pub aliases: u32,
26    /// Field/selection name tokens in selection-set context. A cheap complexity proxy.
27    pub fields: u32,
28    /// `@directive` occurrences.
29    pub directives: u32,
30    /// A schema-introspection meta-field (`__schema` / `__type`) is present. NB:
31    /// `__typename` is deliberately NOT counted (it is benign and ubiquitous).
32    pub has_introspection: bool,
33}
34
35/// Lex `s` and compute its [`GraphqlStats`]. Linear time, no allocation, never panics.
36pub fn graphql_lex(s: &str) -> GraphqlStats {
37    let b = s.as_bytes();
38    let n = b.len();
39    let mut i = 0usize;
40
41    let mut depth: u32 = 0;
42    let mut paren: u32 = 0;
43    let mut st = GraphqlStats::default();
44
45    while i < n {
46        let c = b[i];
47        match c {
48            // `#` line comment → skip to end of line.
49            b'#' => {
50                while i < n && b[i] != b'\n' {
51                    i += 1;
52                }
53            }
54            // Block string `"""…"""` (may contain `"` and newlines; `\` escapes).
55            b'"' if i + 2 < n && b[i + 1] == b'"' && b[i + 2] == b'"' => {
56                i += 3;
57                while i + 2 < n && !(b[i] == b'"' && b[i + 1] == b'"' && b[i + 2] == b'"') {
58                    if b[i] == b'\\' {
59                        i += 1; // skip the escaped byte (incl. an escaped `"""`)
60                    }
61                    i += 1;
62                }
63                i = (i + 3).min(n); // past the closing `"""` (or clamp at EOF)
64            }
65            // Normal string `"…"` (`\` escapes the next byte). A `"` byte never occurs
66            // inside a UTF-8 multibyte sequence, so scanning bytes is safe.
67            b'"' => {
68                i += 1;
69                while i < n && b[i] != b'"' {
70                    if b[i] == b'\\' {
71                        i += 1;
72                    }
73                    i += 1;
74                }
75                i += 1; // past the closing quote (or EOF)
76            }
77            b'(' => {
78                paren += 1;
79                i += 1;
80            }
81            b')' => {
82                paren = paren.saturating_sub(1);
83                i += 1;
84            }
85            b'{' => {
86                if paren == 0 {
87                    depth += 1;
88                    if depth > st.max_depth {
89                        st.max_depth = depth;
90                    }
91                }
92                i += 1;
93            }
94            b'}' => {
95                if paren == 0 {
96                    depth = depth.saturating_sub(1);
97                }
98                i += 1;
99            }
100            // In a selection set the only `:` is an alias separator; argument `:` lives
101            // inside `(` … `)` and is excluded.
102            b':' if paren == 0 => {
103                st.aliases += 1;
104                i += 1;
105            }
106            b'@' => {
107                st.directives += 1;
108                i += 1;
109            }
110            // Name: [_A-Za-z][_0-9A-Za-z]* — counted only as a selection (paren==0, depth>0).
111            _ if c == b'_' || c.is_ascii_alphabetic() => {
112                let start = i;
113                while i < n && (b[i] == b'_' || b[i].is_ascii_alphanumeric()) {
114                    i += 1;
115                }
116                if paren == 0 && depth > 0 {
117                    st.fields += 1;
118                    let name = &b[start..i];
119                    if name == b"__schema" || name == b"__type" {
120                        st.has_introspection = true;
121                    }
122                }
123            }
124            // Whitespace, commas, `$ = ! [ ] . | &` and any other byte: insignificant.
125            _ => {
126                i += 1;
127            }
128        }
129    }
130
131    st
132}
133
134/// If `s` is a JSON envelope carrying GraphQL operation text(s) — a `{"query":"<doc>"}`
135/// object or a `[{"query":…}, …]` batch array — return those operation strings; `None`
136/// when `s` is a bare GraphQL document or not such an envelope (the caller then lexes `s`
137/// directly).
138///
139/// Why this exists: the GraphQL GET transport is `?query=<document>`, but gotestwaf (and
140/// some clients) send `?query={"query":"<document>"}` — the entire JSON *envelope* in the
141/// param value. [`graphql_lex`] skips string contents, so an introspection / DoS document
142/// hidden inside a JSON string literal is invisible to it (depth ≈ 1, `__schema` unseen).
143/// Unwrapping the envelope hands the real document to the lexer. Only the operation-
144/// carrying `query` key is extracted (top level + batch elements), mirroring the JSON-body
145/// transport (`is_graphql_query_key`); a nested `variables.query` is NOT treated as an
146/// operation. Returns `None` (not `Some(vec![])`) when no `query` leaf is found, so a
147/// benign JSON value with no operation falls back to raw lexing.
148pub fn unwrap_query_envelope(s: &str) -> Option<Vec<String>> {
149    let trimmed = s.trim_start();
150    if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
151        return None;
152    }
153    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
154    let mut out = Vec::new();
155    match &value {
156        // Single operation envelope.
157        serde_json::Value::Object(_) => push_query_leaf(&value, &mut out),
158        // Batched operations: an array of `{"query": …}` objects.
159        serde_json::Value::Array(arr) => arr.iter().for_each(|el| push_query_leaf(el, &mut out)),
160        _ => {}
161    }
162    (!out.is_empty()).then_some(out)
163}
164
165/// Push `v["query"]` into `out` when `v` is an object with a string `query` field.
166fn push_query_leaf(v: &serde_json::Value, out: &mut Vec<String>) {
167    if let serde_json::Value::Object(map) = v {
168        if let Some(serde_json::Value::String(q)) = map.get("query") {
169            out.push(q.clone());
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn unwrap_envelope_single_operation() {
180        // The gotestwaf GET shape: the whole JSON envelope sits in `?query=`.
181        let ops = unwrap_query_envelope(r#"{"query":"query{__schema{name}}"}"#).unwrap();
182        assert_eq!(ops, vec!["query{__schema{name}}".to_string()]);
183        // And the introspection is now visible to the lexer (it was hidden in a string).
184        assert!(graphql_lex(&ops[0]).has_introspection);
185    }
186
187    #[test]
188    fn unwrap_envelope_batch_array() {
189        let ops = unwrap_query_envelope(r#"[{"query":"{a}"},{"query":"{b}"}]"#).unwrap();
190        assert_eq!(ops, vec!["{a}".to_string(), "{b}".to_string()]);
191    }
192
193    #[test]
194    fn unwrap_envelope_rejects_bare_document_and_non_envelope() {
195        // A bare GraphQL document is NOT JSON → caller lexes it raw.
196        assert!(unwrap_query_envelope("query{__schema{name}}").is_none());
197        assert!(unwrap_query_envelope("{__typename}").is_none()); // not valid JSON
198        // Valid JSON but no `query` operation leaf → fall back to raw.
199        assert!(unwrap_query_envelope(r#"{"a":1}"#).is_none());
200        assert!(unwrap_query_envelope(r#"{"variables":{"query":"x"}}"#).is_none());
201    }
202
203    #[test]
204    fn simple_query_depth_and_fields() {
205        let st = graphql_lex("query { user { id name } }");
206        assert_eq!(st.max_depth, 2); // user { … } inside the outer selection set
207        assert_eq!(st.fields, 3); // user, id, name
208        assert_eq!(st.aliases, 0);
209        assert!(!st.has_introspection);
210    }
211
212    #[test]
213    fn paren_aware_trap_flat_selection_deep_input_object() {
214        // The Phase-11 Step-0 trap: a deeply nested input OBJECT in an argument, but a
215        // FLAT selection set. Depth must be 2 (mutation { c { id } }), NOT ~6.
216        let st = graphql_lex("mutation{c(input:{a:{b:{c:{d:1}}}}){id}}");
217        assert_eq!(st.max_depth, 2);
218    }
219
220    #[test]
221    fn deep_nesting() {
222        let mut q = String::from("query");
223        for _ in 0..20 {
224            q.push_str("{a");
225        }
226        q.push_str("{id}");
227        for _ in 0..21 {
228            q.push('}');
229        }
230        assert_eq!(graphql_lex(&q).max_depth, 21);
231    }
232
233    #[test]
234    fn alias_bomb() {
235        let st = graphql_lex("query{a:f b:f c:f d:f}");
236        assert_eq!(st.aliases, 4);
237    }
238
239    #[test]
240    fn introspection_detected() {
241        assert!(graphql_lex("query{__schema{types{name}}}").has_introspection);
242        assert!(graphql_lex("{__type(name:\"X\"){fields{name}}}").has_introspection);
243    }
244
245    #[test]
246    fn typename_is_not_introspection() {
247        assert!(!graphql_lex("query{user{__typename id}}").has_introspection);
248    }
249
250    #[test]
251    fn directives_counted() {
252        let st = graphql_lex("query{field @include(if:true) other @skip(if:false)}");
253        assert_eq!(st.directives, 2);
254    }
255
256    #[test]
257    fn braces_in_string_do_not_count() {
258        // The `}}}}` inside the string must not close the selection set.
259        let st = graphql_lex(r#"{a(x:"}}}}}}")b}"#);
260        assert_eq!(st.max_depth, 1);
261    }
262
263    #[test]
264    fn braces_in_comment_do_not_count() {
265        let st = graphql_lex("{a # }}}} not real\n b}");
266        assert_eq!(st.max_depth, 1);
267    }
268
269    #[test]
270    fn braces_in_block_string_do_not_count() {
271        let st = graphql_lex(r#"{a(x:"""}}} still a string """)b}"#);
272        assert_eq!(st.max_depth, 1);
273    }
274
275    #[test]
276    fn unterminated_string_and_braces_terminate() {
277        // Must not hang / panic on malformed input.
278        let _ = graphql_lex("query{a(x:\"unterminated");
279        let _ = graphql_lex("{{{{{{{{");
280        let _ = graphql_lex("\"\"\"unterminated block");
281        let _ = graphql_lex("");
282    }
283}