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}