1use 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 fn operations(&self, ctx: &RequestContext) -> Vec<String> {
36 let mut carriers = Vec::new();
38
39 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 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
78fn 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
103fn 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 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 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 assert!(is_graphql_query_key("query")); assert!(is_graphql_query_key("0.query")); assert!(is_graphql_query_key("11.query"));
191 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}