security_rust/injection/
graphql_injection.rs1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
14 Regex::new(concat!(
15 r"(?i:__schema\s*\{",
16 r"|__type\s*\{)",
17 ))
18 .unwrap()
19});
20
21static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
28 Regex::new(concat!(
29 r"(?i:__typename)",
30 r"|\{[^{}]*\{[^{}]*\{[^{}]*\{[^{}]*\{",
31 ))
32 .unwrap()
33});
34
35pub struct GraphQlInjectionDetector;
36
37impl Detector for GraphQlInjectionDetector {
38 fn name(&self) -> &'static str {
39 "graphql_injection"
40 }
41
42 fn detect(&self, input: &str) -> Option<DetectionResult> {
43 regex_detect(
44 std::slice::from_ref(&*STRONG_PATTERNS),
45 self.name(),
46 AttackCategory::Injection,
47 Severity::Medium,
48 "GraphQL injection/introspection detected",
49 input,
50 )
51 .or_else(|| {
52 regex_detect(
53 std::slice::from_ref(&*WEAK_PATTERNS),
54 self.name(),
55 AttackCategory::Injection,
56 Severity::Low,
57 "GraphQL introspection field / nesting present (weak signal)",
58 input,
59 )
60 })
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 fn det() -> GraphQlInjectionDetector {
69 GraphQlInjectionDetector
70 }
71
72 fn assert_hit_at(input: &str, severity: Severity) {
73 crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
74 }
75
76 #[test]
77 fn name_is_graphql_injection() {
78 assert_eq!(det().name(), "graphql_injection");
79 }
80
81 #[test]
82 fn detects_common_payloads() {
83 for (input, severity) in [
84 ("{ __schema { types { name } } }", Severity::Medium),
86 ("query { __type { name } }", Severity::Medium),
87 ("fragment F on __Type { name }", Severity::Medium),
88 ("query { __typename }", Severity::Low),
90 ("{a{b{c{d{e{f}}}}}}", Severity::Low),
91 ] {
92 assert_hit_at(input, severity);
93 }
94 }
95
96 #[test]
99 fn client_code_and_nested_json_are_not_high() {
100 for (input, severity) in [
101 ("const key = data.__typename + \":\" + data.id;", Severity::Low),
102 (
103 r#"{"response": {"data": {"user": {"profile": {"name": "Ada"}}}}}"#,
104 Severity::Low,
105 ),
106 ("Apollo adds `__typename` to every selection set automatically.", Severity::Low),
107 ] {
108 assert_hit_at(input, severity);
109 }
110 }
111
112 #[test]
113 fn benign_inputs_not_detected() {
114 for input in [
115 "Hello, this is a normal text input. Nothing suspicious here.",
116 "query { user(id: 1) { name } }",
117 r#"{"a": {"b": {"c": {"d": 1}}}}"#,
118 "The schema was updated today",
119 "Introspection queries use `__schema` and `__type`; disable them in production.",
121 ] {
122 assert!(det().detect(input).is_none(), "false positive: {input}");
123 }
124 }
125
126 #[test]
127 fn edge_cases() {
128 assert!(det().detect("").is_none());
129 assert!(det().detect(" \t\n ").is_none());
130 assert!(det().detect("你好世界 こんにちは").is_none());
131 assert!(det().detect("{__schem}").is_none());
133 assert!(det().detect("schema").is_none());
134 assert!(det().detect("{{{{").is_none());
135 }
136
137 #[test]
138 fn obfuscated_variants_detected() {
139 for (input, severity) in [
140 ("{ __SCHEMA { types } }", Severity::Medium),
141 ("__Type { name }", Severity::Medium),
142 ("__TYPENAME", Severity::Low),
143 ] {
144 assert_hit_at(input, severity);
145 }
146 }
147}