Skip to main content

api_scanner/scanner/
graphql.rs

1// src/scanner/graphql.rs
2
3use async_trait::async_trait;
4use rand::seq::SliceRandom;
5use serde_json::{json, Value};
6use tracing::debug;
7use url::Url;
8
9use crate::{
10    config::Config,
11    error::CapturedError,
12    http_client::HttpClient,
13    reports::{Finding, Severity},
14};
15
16use super::Scanner;
17
18pub struct GraphqlScanner;
19
20impl GraphqlScanner {
21    pub fn new(_config: &Config) -> Self {
22        Self
23    }
24}
25
26static GQL_PATHS: &[&str] = &[
27    "/graphql",
28    "/graphiql",
29    "/api/graphql",
30    "/v1/graphql",
31    "/query",
32    "/gql",
33];
34
35// ── Payloads ──────────────────────────────────────────────────────────────────
36
37fn introspection_payload() -> Value {
38    json!({
39        "query": "{ __schema { queryType { name } types { kind name \
40                   fields { name args { name type { name kind } } } } } }"
41    })
42}
43
44fn field_suggestion_payload() -> Value {
45    json!({ "query": "{ __typ }" })
46}
47
48fn batch_payload() -> Value {
49    json!([
50        { "query": "{ __typename }" },
51        { "query": "{ __typename }" }
52    ])
53}
54
55fn alias_dos_payload() -> Value {
56    let aliases: String = (0..10).map(|i| format!("a{i}: __typename ")).collect();
57    json!({ "query": format!("{{ {aliases} }}") })
58}
59
60// ── Sensitive type / field names that warrant a finding ───────────────────────
61static SENSITIVE_TYPES: &[&str] = &[
62    "user",
63    "users",
64    "admin",
65    "password",
66    "secret",
67    "token",
68    "apikey",
69    "api_key",
70    "credential",
71    "auth",
72    "session",
73    "privatekey",
74    "ssn",
75    "creditcard",
76    "payment",
77    "billing",
78];
79
80#[async_trait]
81impl Scanner for GraphqlScanner {
82    fn name(&self) -> &'static str {
83        "graphql"
84    }
85
86    async fn scan(
87        &self,
88        url: &str,
89        client: &HttpClient,
90        _config: &Config,
91    ) -> (Vec<Finding>, Vec<CapturedError>) {
92        let mut findings = Vec::new();
93        let mut errors = Vec::new();
94
95        // Build candidate endpoint list
96        let lower = url.to_ascii_lowercase();
97        let already_gql = GQL_PATHS.iter().any(|p| lower.ends_with(p));
98        let base_path_looks_graphql = seed_path_looks_graphql(url);
99
100        let mut candidates: Vec<String> = Vec::new();
101        if already_gql || base_path_looks_graphql {
102            candidates.push(url.to_string());
103        }
104        if !already_gql {
105            let base = url.trim_end_matches('/');
106            let mut paths: Vec<&str> = GQL_PATHS.to_vec();
107            let mut rng = rand::thread_rng();
108            paths.shuffle(&mut rng);
109            for path in paths {
110                candidates.push(format!("{base}{path}"));
111            }
112        }
113
114        for candidate in &candidates {
115            probe_endpoint(candidate, client, &mut findings, &mut errors).await;
116        }
117
118        (findings, errors)
119    }
120}
121
122// ── Per-endpoint probing ──────────────────────────────────────────────────────
123
124async fn probe_endpoint(
125    url: &str,
126    client: &HttpClient,
127    findings: &mut Vec<Finding>,
128    errors: &mut Vec<CapturedError>,
129) {
130    // ── Step 1: Introspection ─────────────────────────────────────────────────
131    let payload = introspection_payload();
132    let resp = match client.post_json(url, &payload).await {
133        Ok(r) => r,
134        Err(e) => {
135            errors.push(e);
136            return;
137        }
138    };
139
140    // Non-GraphQL or hard error — skip remaining checks for this candidate.
141    // Keep 400 (often GraphQL validation error), skip auth failures and 5xx.
142    if resp.status >= 500 || resp.status == 401 || resp.status == 403 || resp.status == 429 {
143        return;
144    }
145    if resp.status == 404 {
146        return;
147    }
148
149    let body: Value = match serde_json::from_str(&resp.body) {
150        Ok(v) => v,
151        Err(_) => return,
152    };
153
154    // Locate the `types` array regardless of whether it sits under `data`
155    let types_ptr = body
156        .pointer("/data/__schema/types")
157        .or_else(|| body.pointer("/__schema/types"));
158
159    if let Some(types_val) = types_ptr {
160        // ── 1a. Introspection enabled ─────────────────────────────────────────
161        findings.push(
162            Finding::new(
163                url,
164                "graphql/introspection-enabled",
165                "GraphQL introspection enabled",
166                Severity::Medium,
167                "GraphQL introspection is enabled. Full schema is publicly discoverable.",
168                "graphql",
169            )
170            .with_evidence(format!(
171                "POST {url}\nPayload: {payload}\nStatus: {}",
172                resp.status
173            ))
174            .with_remediation(
175                "Disable introspection in production or restrict it to authenticated/admin users.",
176            ),
177        );
178
179        // ── 1b. Sensitive type / field names in schema ────────────────────────
180        if let Some(types) = types_val.as_array() {
181            let mut matched: Vec<String> = Vec::new();
182
183            for t in types {
184                let type_name = t["name"].as_str().unwrap_or("").to_ascii_lowercase();
185
186                if is_sensitive(&type_name) && !type_name.starts_with("__") {
187                    matched.push(format!("type:{type_name}"));
188                }
189
190                if let Some(fields) = t["fields"].as_array() {
191                    for field in fields {
192                        let fname = field["name"].as_str().unwrap_or("").to_ascii_lowercase();
193                        if is_sensitive(&fname) {
194                            matched.push(format!(
195                                "{}::{}",
196                                t["name"].as_str().unwrap_or("?"),
197                                fname
198                            ));
199                        }
200                    }
201                }
202            }
203
204            if !matched.is_empty() {
205                findings.push(Finding::new(
206                    url,
207                    "graphql/sensitive-schema-fields",
208                    "Sensitive GraphQL schema fields",
209                    Severity::High,
210                    format!(
211                        "Schema exposes potentially sensitive types/fields: {}",
212                        matched.join(", ")
213                    ),
214                    "graphql",
215                )
216                .with_evidence(format!("Matched names: {}", matched.join(", ")))
217                .with_remediation(
218                    "Review schema for sensitive fields and enforce authorization on resolvers.",
219                ));
220            }
221        }
222    } else if let Some(errors_val) = body.get("errors") {
223        debug!("[graphql] introspection disabled at {url}: {errors_val}");
224        findings.push(
225            Finding::new(
226                url,
227                "graphql/endpoint-detected",
228                "GraphQL endpoint detected",
229                Severity::Info,
230                "GraphQL endpoint detected; introspection is disabled (good).",
231                "graphql",
232            )
233            .with_evidence(format!("Errors: {errors_val}"))
234            .with_remediation(
235                "Ensure the endpoint requires authentication and applies query cost limits.",
236            ),
237        );
238    }
239
240    // ── Step 2: Field suggestions (information leakage) ──────────────────────
241    let sugg_payload = field_suggestion_payload();
242    if let Ok(sr) = client.post_json(url, &sugg_payload).await {
243        if let Ok(sb) = serde_json::from_str::<Value>(&sr.body) {
244            let has_suggestion = sb["errors"]
245                .as_array()
246                .map(|errs| {
247                    errs.iter().any(|e| {
248                        e["message"]
249                            .as_str()
250                            .map(|m| m.contains("Did you mean") || m.contains("did you mean"))
251                            .unwrap_or(false)
252                    })
253                })
254                .unwrap_or(false);
255
256            if has_suggestion {
257                findings.push(
258                    Finding::new(
259                        url,
260                        "graphql/field-suggestions",
261                        "GraphQL field suggestions enabled",
262                        Severity::Low,
263                        "Server returns field-name suggestions in errors, leaking schema \
264                     information even with introspection disabled.",
265                        "graphql",
266                    )
267                    .with_evidence(sr.body.chars().take(512).collect::<String>())
268                    .with_remediation(
269                        "Disable field suggestions or hide detailed error messages in production.",
270                    ),
271                );
272            }
273        }
274    }
275
276    // ── Step 3: Query batching ────────────────────────────────────────────────
277    let batch = batch_payload();
278    if let Ok(br) = client.post_json(url, &batch).await {
279        if let Ok(bv) = serde_json::from_str::<Value>(&br.body) {
280            if bv.as_array().map(|a| a.len() >= 2).unwrap_or(false) {
281                findings.push(
282                    Finding::new(
283                        url,
284                        "graphql/batching-enabled",
285                        "GraphQL query batching enabled",
286                        Severity::Low,
287                        "GraphQL query batching is enabled. This can amplify DoS impact \
288                     and may bypass rate limiting applied per-request.",
289                        "graphql",
290                    )
291                    .with_evidence(br.body.chars().take(256).collect::<String>())
292                    .with_remediation(
293                        "Disable batching or enforce per-operation rate limits and cost controls.",
294                    ),
295                );
296            }
297        }
298    }
299
300    // ── Step 4: Alias amplification probe ────────────────────────────────────
301    let alias = alias_dos_payload();
302    if let Ok(ar) = client.post_json(url, &alias).await {
303        if let Ok(av) = serde_json::from_str::<Value>(&ar.body) {
304            let resolved = (0..10)
305                .filter(|i| av.pointer(&format!("/data/a{i}")).is_some())
306                .count();
307            if resolved >= 10 {
308                findings.push(
309                    Finding::new(
310                        url,
311                        "graphql/alias-amplification",
312                        "GraphQL alias amplification possible",
313                        Severity::Low,
314                        "Server resolves all query aliases without restriction. \
315                     Malicious clients can craft deeply aliased queries to amplify \
316                     server-side work (alias-based DoS).",
317                        "graphql",
318                    )
319                    .with_evidence(format!("{resolved}/10 aliases resolved"))
320                    .with_remediation(
321                        "Enforce query depth/complexity limits and alias count caps.",
322                    ),
323                );
324            }
325        }
326    }
327
328    // ── Step 5: GraphiQL / playground UI exposed ──────────────────────────────
329    if let Ok(gr) = client.get(url).await {
330        let body_lower = gr.body.to_ascii_lowercase();
331        if body_lower.contains("graphiql") || body_lower.contains("graphql playground") {
332            findings.push(
333                Finding::new(
334                    url,
335                    "graphql/playground-exposed",
336                    "GraphQL IDE exposed",
337                    Severity::Low,
338                    "GraphQL IDE (GraphiQL / Playground) is exposed. Attackers can \
339                 interactively explore and query the API.",
340                    "graphql",
341                )
342                .with_evidence(format!("GET {url} → HTML contains IDE marker"))
343                .with_remediation(
344                    "Disable GraphiQL/Playground in production or restrict access to admins.",
345                ),
346            );
347        }
348    }
349}
350
351// ── Helpers ───────────────────────────────────────────────────────────────────
352
353fn is_sensitive(name: &str) -> bool {
354    SENSITIVE_TYPES
355        .iter()
356        .any(|&s| name == s || name.contains(s))
357}
358
359fn seed_path_looks_graphql(url: &str) -> bool {
360    let Ok(parsed) = Url::parse(url) else {
361        return false;
362    };
363    let path = parsed.path().to_ascii_lowercase();
364    path.contains("graphql") || path.ends_with("/gql")
365}