use async_graphql_parser::types::{
DocumentOperations, ExecutableDocument, OperationDefinition, Selection, SelectionSet,
};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub(crate) struct GraphqlLimits {
pub max_depth: u32,
pub max_complexity: u32,
pub allow_introspection: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GuardVerdict {
Allow,
Reject(String),
}
pub(crate) const MAX_QUERY_BYTES: usize = 1024 * 1024;
pub(crate) fn error_response(reason: &str) -> axum::response::Response {
json_error(axum::http::StatusCode::BAD_REQUEST, reason)
}
pub(crate) fn too_large_response() -> axum::response::Response {
json_error(
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
&format!("GraphQL request exceeds the {MAX_QUERY_BYTES}-byte edge limit"),
)
}
fn json_error(status: axum::http::StatusCode, message: &str) -> axum::response::Response {
use axum::response::IntoResponse;
let body = serde_json::json!({ "errors": [ { "message": message } ] }).to_string();
(
status,
[(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
)],
body,
)
.into_response()
}
const DEFAULT_MAX_DEPTH: u32 = 15;
const DEFAULT_MAX_COMPLEXITY: u32 = 1000;
pub(crate) fn limits_from(cfg: &boatramp_core::config::HandlerGraphqlConfig) -> GraphqlLimits {
GraphqlLimits {
max_depth: cfg.max_depth.unwrap_or(DEFAULT_MAX_DEPTH),
max_complexity: cfg.max_complexity.unwrap_or(DEFAULT_MAX_COMPLEXITY),
allow_introspection: cfg.introspection.unwrap_or(false),
}
}
pub(crate) fn query_from_body(content_type: Option<&str>, body: &[u8]) -> Option<String> {
if content_type.is_some_and(|ct| ct.contains("application/graphql")) {
return Some(String::from_utf8_lossy(body).into_owned());
}
let value: serde_json::Value = serde_json::from_slice(body).ok()?;
value.get("query")?.as_str().map(str::to_string)
}
pub(crate) fn guard_query(query: &str, limits: &GraphqlLimits) -> GuardVerdict {
let doc = match async_graphql_parser::parse_query(query) {
Ok(doc) => doc,
Err(err) => return GuardVerdict::Reject(format!("invalid GraphQL query: {err}")),
};
if !limits.allow_introspection && has_root_introspection(&doc) {
return GuardVerdict::Reject("introspection is disabled".to_string());
}
let (depth, complexity) = measure(&doc);
if depth > limits.max_depth {
return GuardVerdict::Reject(format!(
"query depth {depth} exceeds the limit of {}",
limits.max_depth
));
}
if complexity > limits.max_complexity {
return GuardVerdict::Reject(format!(
"query complexity {complexity} exceeds the limit of {}",
limits.max_complexity
));
}
GuardVerdict::Allow
}
fn operations(doc: &ExecutableDocument) -> Vec<&OperationDefinition> {
match &doc.operations {
DocumentOperations::Single(op) => vec![&op.node],
DocumentOperations::Multiple(map) => map.values().map(|op| &op.node).collect(),
}
}
fn has_root_introspection(doc: &ExecutableDocument) -> bool {
operations(doc).iter().any(|op| {
op.selection_set.node.items.iter().any(|sel| {
matches!(&sel.node, Selection::Field(f)
if f.node.name.node == "__schema" || f.node.name.node == "__type")
})
})
}
fn measure(doc: &ExecutableDocument) -> (u32, u32) {
let fragments: HashMap<&str, &SelectionSet> = doc
.fragments
.iter()
.map(|(name, f)| (name.as_str(), &f.node.selection_set.node))
.collect();
let mut max_depth = 0;
let mut complexity = 0;
for op in operations(doc) {
let mut visiting = HashSet::new();
let (d, c) = walk(&op.selection_set.node, &fragments, 1, &mut visiting);
max_depth = max_depth.max(d);
complexity += c;
}
(max_depth, complexity)
}
fn walk<'a>(
ss: &'a SelectionSet,
fragments: &HashMap<&'a str, &'a SelectionSet>,
depth: u32,
visiting: &mut HashSet<&'a str>,
) -> (u32, u32) {
let mut max_depth = depth;
let mut count = 0;
for sel in &ss.items {
match &sel.node {
Selection::Field(field) => {
count += 1;
let child = &field.node.selection_set.node;
if !child.items.is_empty() {
let (d, c) = walk(child, fragments, depth + 1, visiting);
max_depth = max_depth.max(d);
count += c;
}
}
Selection::InlineFragment(inline) => {
let (d, c) = walk(&inline.node.selection_set.node, fragments, depth, visiting);
max_depth = max_depth.max(d);
count += c;
}
Selection::FragmentSpread(spread) => {
let name = spread.node.fragment_name.node.as_str();
if visiting.insert(name) {
if let Some(frag_ss) = fragments.get(name) {
let (d, c) = walk(frag_ss, fragments, depth, visiting);
max_depth = max_depth.max(d);
count += c;
}
visiting.remove(name);
}
}
}
}
(max_depth, count)
}
#[cfg(test)]
mod tests {
use super::*;
fn limits(max_depth: u32, max_complexity: u32, allow_introspection: bool) -> GraphqlLimits {
GraphqlLimits {
max_depth,
max_complexity,
allow_introspection,
}
}
#[test]
fn flat_query_is_depth_one() {
let v = guard_query("{ a b c }", &limits(3, 100, true));
assert_eq!(v, GuardVerdict::Allow);
}
#[test]
fn nested_query_depth_is_measured_and_capped() {
let q = "{ a { b { c } } }";
assert_eq!(guard_query(q, &limits(3, 100, true)), GuardVerdict::Allow);
match guard_query(q, &limits(2, 100, true)) {
GuardVerdict::Reject(r) => assert!(r.contains("depth 3") && r.contains("limit of 2")),
other => panic!("expected reject, got {other:?}"),
}
}
#[test]
fn complexity_counts_all_fields_including_nested() {
let q = "{ a { b c } d }";
assert_eq!(guard_query(q, &limits(10, 4, true)), GuardVerdict::Allow);
match guard_query(q, &limits(10, 3, true)) {
GuardVerdict::Reject(r) => assert!(r.contains("complexity 4")),
other => panic!("expected reject, got {other:?}"),
}
}
#[test]
fn fragments_are_expanded_for_depth_so_they_cannot_hide_nesting() {
let q = "{ a { ...F } } fragment F on T { b { c } }";
assert_eq!(guard_query(q, &limits(3, 100, true)), GuardVerdict::Allow);
assert!(matches!(
guard_query(q, &limits(2, 100, true)),
GuardVerdict::Reject(_)
));
}
#[test]
fn a_fragment_cycle_does_not_loop_forever() {
let q = "{ a { ...F } } fragment F on T { b { ...F } }";
let _ = guard_query(q, &limits(100, 100, true));
}
#[test]
fn introspection_is_gated() {
let q = "{ __schema { types { name } } }";
assert!(matches!(
guard_query(q, &limits(100, 100, false)),
GuardVerdict::Reject(r) if r.contains("introspection")
));
assert_eq!(guard_query(q, &limits(100, 100, true)), GuardVerdict::Allow);
assert_eq!(
guard_query("{ a __typename }", &limits(100, 100, false)),
GuardVerdict::Allow
);
}
#[test]
fn unparsable_query_is_rejected() {
assert!(matches!(
guard_query("{ a { b ", &limits(10, 10, true)),
GuardVerdict::Reject(r) if r.contains("invalid GraphQL")
));
}
}