Skip to main content

security_rust/injection/
graphql_injection.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// 强信号:内省字段处在**查询形态**里 —— 带选择集(`__schema {`)。
8/// 散文/文档里提到 `__schema`(「内省查询用 `__schema` 和 `__type`」)不报。
9/// 2 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
10/// `find`,干净输入下 2 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
11/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
12/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 2 条分支。
13static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
14    Regex::new(concat!(
15        r"(?i:__schema\s*\{",
16        r"|__type\s*\{)",
17    ))
18    .unwrap()
19});
20
21/// 弱信号:`__typename` 由 Apollo/Relay 客户端自动加进每条查询,是正常前端代码的
22/// 固定内容;5 层花括号与深层 JSON 配置形状完全相同。单条不拒绝。
23/// 2 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
24/// `find`,干净输入下 2 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
25/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
26/// 内联 flag 一律裹进 `(?i:…)` —— 裸 `(?i)` 的作用域会蔓延到它后面拼进来的分支。
27static 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            // 强信号:内省字段带选择集,Medium
85            ("{ __schema { types { name } } }", Severity::Medium),
86            ("query { __type { name } }", Severity::Medium),
87            ("fragment F on __Type { name }", Severity::Medium),
88            // 弱信号:客户端固定字段与深层嵌套,Low
89            ("query { __typename }", Severity::Low),
90            ("{a{b{c{d{e{f}}}}}}", Severity::Low),
91        ] {
92            assert_hit_at(input, severity);
93        }
94    }
95
96    /// 正常内容:Apollo 客户端会自动把 `__typename` 加进每条查询,5 层对象嵌套
97    /// 是配置 JSON 的常态。两者曾分别被判 Medium —— 叠加其它模块即可越过拒绝线。
98    #[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            // 文档在讨论内省,不是内省查询:没有选择集
120            "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        // near misses: not quite the introspection keyword, or not deep enough
132        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}