use waf_core::{Config, Decision, GraphqlConfig, ParsedBody, Phase, RequestContext, WafModule};
use waf_normalizer::graphql::{graphql_lex, unwrap_query_envelope};
#[derive(Default)]
pub struct GraphqlModule {
cfg: GraphqlConfig,
}
impl GraphqlModule {
pub fn new() -> Self {
Self::default()
}
fn operations(&self, ctx: &RequestContext) -> Vec<String> {
let mut carriers = Vec::new();
if content_type(ctx).trim_start().starts_with("application/graphql") {
if let ParsedBody::Raw(b) = &ctx.normalized.body {
if let Ok(s) = std::str::from_utf8(b) {
carriers.push(s.to_string());
}
}
return expand(carriers);
}
let path_match = self
.cfg
.paths
.iter()
.any(|p| ctx.normalized.path.eq_ignore_ascii_case(p));
if !path_match {
return Vec::new();
}
if let ParsedBody::JsonFlattened(pairs) = &ctx.normalized.body {
for (k, v) in pairs {
if is_graphql_query_key(k) {
carriers.push(v.clone());
}
}
}
if ctx.method.eq_ignore_ascii_case("GET") {
for (k, v) in &ctx.normalized.query_params {
if k == "query" {
carriers.push(v.clone());
}
}
}
expand(carriers)
}
}
fn expand(carriers: Vec<String>) -> Vec<String> {
let mut out = Vec::with_capacity(carriers.len());
for c in carriers {
match unwrap_query_envelope(&c) {
Some(docs) => out.extend(docs),
None => out.push(c),
}
}
out
}
fn content_type(ctx: &RequestContext) -> &str {
ctx.normalized
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.as_str())
.unwrap_or("")
}
fn is_graphql_query_key(k: &str) -> bool {
if k == "query" {
return true;
}
match k.strip_suffix(".query") {
Some(prefix) => !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()),
None => false,
}
}
fn reject(reason: &'static str) -> Decision {
Decision::Reject {
rule_id: "graphql".to_string(),
reason: reason.to_string(),
status: 400,
retry_after: None,
}
}
impl WafModule for GraphqlModule {
fn id(&self) -> &str {
"graphql"
}
fn phase(&self) -> Phase {
Phase::Body
}
fn structural(&self) -> bool {
true
}
fn init(&mut self, cfg: &Config) {
self.cfg = cfg.modules.graphql.clone();
}
fn inspect(&self, ctx: &RequestContext) -> Decision {
if !self.cfg.enabled {
return Decision::Allow;
}
let ops = self.operations(ctx);
if ops.is_empty() {
return Decision::Allow;
}
if ops.len() as u32 > self.cfg.max_batch {
return reject("graphql batch operation count exceeds limit");
}
for op in &ops {
let st = graphql_lex(op);
if st.max_depth > self.cfg.max_depth {
return reject("graphql query nesting depth exceeds limit");
}
if st.aliases > self.cfg.max_aliases {
return reject("graphql alias count exceeds limit");
}
if st.fields > self.cfg.max_fields {
return reject("graphql field/complexity exceeds limit");
}
if st.directives > self.cfg.max_directives {
return reject("graphql directive count exceeds limit");
}
if self.cfg.block_introspection && st.has_introspection {
return Decision::Block {
rule_id: "graphql-introspection".to_string(),
reason: "graphql schema introspection is blocked".to_string(),
};
}
}
Decision::Allow
}
}
#[cfg(test)]
mod tests {
use super::is_graphql_query_key;
#[test]
fn query_key_matching() {
assert!(is_graphql_query_key("query")); assert!(is_graphql_query_key("0.query")); assert!(is_graphql_query_key("11.query"));
assert!(!is_graphql_query_key("variables.query"));
assert!(!is_graphql_query_key("variables.filter.query"));
assert!(!is_graphql_query_key("a.query"));
assert!(!is_graphql_query_key("query.sub"));
assert!(!is_graphql_query_key("operationName"));
assert!(!is_graphql_query_key(".query"));
}
}