use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i:__schema\s*\{",
r"|__type\s*\{)",
))
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i:__typename)",
r"|\{[^{}]*\{[^{}]*\{[^{}]*\{[^{}]*\{",
))
.unwrap()
});
pub struct GraphQlInjectionDetector;
impl Detector for GraphQlInjectionDetector {
fn name(&self) -> &'static str {
"graphql_injection"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Medium,
"GraphQL injection/introspection detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Low,
"GraphQL introspection field / nesting present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn det() -> GraphQlInjectionDetector {
GraphQlInjectionDetector
}
fn assert_hit_at(input: &str, severity: Severity) {
crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
}
#[test]
fn name_is_graphql_injection() {
assert_eq!(det().name(), "graphql_injection");
}
#[test]
fn detects_common_payloads() {
for (input, severity) in [
("{ __schema { types { name } } }", Severity::Medium),
("query { __type { name } }", Severity::Medium),
("fragment F on __Type { name }", Severity::Medium),
("query { __typename }", Severity::Low),
("{a{b{c{d{e{f}}}}}}", Severity::Low),
] {
assert_hit_at(input, severity);
}
}
#[test]
fn client_code_and_nested_json_are_not_high() {
for (input, severity) in [
("const key = data.__typename + \":\" + data.id;", Severity::Low),
(
r#"{"response": {"data": {"user": {"profile": {"name": "Ada"}}}}}"#,
Severity::Low,
),
("Apollo adds `__typename` to every selection set automatically.", Severity::Low),
] {
assert_hit_at(input, severity);
}
}
#[test]
fn benign_inputs_not_detected() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"query { user(id: 1) { name } }",
r#"{"a": {"b": {"c": {"d": 1}}}}"#,
"The schema was updated today",
"Introspection queries use `__schema` and `__type`; disable them in production.",
] {
assert!(det().detect(input).is_none(), "false positive: {input}");
}
}
#[test]
fn edge_cases() {
assert!(det().detect("").is_none());
assert!(det().detect(" \t\n ").is_none());
assert!(det().detect("你好世界 こんにちは").is_none());
assert!(det().detect("{__schem}").is_none());
assert!(det().detect("schema").is_none());
assert!(det().detect("{{{{").is_none());
}
#[test]
fn obfuscated_variants_detected() {
for (input, severity) in [
("{ __SCHEMA { types } }", Severity::Medium),
("__Type { name }", Severity::Medium),
("__TYPENAME", Severity::Low),
] {
assert_hit_at(input, severity);
}
}
}