Skip to main content

api_scanner/scanner/
rate_limit.rs

1use async_trait::async_trait;
2use dashmap::DashSet;
3use rand::Rng;
4use std::{collections::HashMap, sync::Arc};
5use url::Url;
6
7use crate::{
8    config::Config,
9    error::CapturedError,
10    http_client::{HttpClient, HttpResponse},
11    reports::{Finding, Severity},
12};
13
14use super::{
15    common::{errors::collect_results, probe::BurstProbe},
16    Scanner,
17};
18
19pub struct RateLimitScanner {
20    checked_hosts: Arc<DashSet<String>>,
21}
22
23impl RateLimitScanner {
24    pub fn new(_config: &Config) -> Self {
25        Self {
26            checked_hosts: Arc::new(DashSet::new()),
27        }
28    }
29}
30
31const BURST_REQUESTS: usize = 12;
32const BYPASS_REQUESTS: usize = 3;
33
34fn random_publicish_ipv4() -> String {
35    let mut rng = rand::thread_rng();
36    const FIRST_OCTETS: &[u8] = &[
37        11, 23, 31, 45, 52, 63, 79, 91, 103, 121, 138, 151, 166, 178, 185, 199, 216,
38    ];
39    let a = FIRST_OCTETS[rng.gen_range(0..FIRST_OCTETS.len())];
40    let b = rng.gen_range(1..=254);
41    let c = rng.gen_range(1..=254);
42    let d = rng.gen_range(1..=254);
43    format!("{a}.{b}.{c}.{d}")
44}
45
46#[derive(Default)]
47struct BurstStats {
48    success: usize,
49    too_many: usize,
50    saw_rate_limit_headers: bool,
51    saw_retry_after: bool,
52    statuses: HashMap<u16, usize>,
53}
54
55#[async_trait]
56impl Scanner for RateLimitScanner {
57    fn name(&self) -> &'static str {
58        "rate_limit"
59    }
60
61    async fn scan(
62        &self,
63        url: &str,
64        client: &HttpClient,
65        config: &Config,
66    ) -> (Vec<Finding>, Vec<CapturedError>) {
67        if !config.active_checks {
68            return (Vec::new(), Vec::new());
69        }
70
71        let mut findings = Vec::new();
72        let mut errors = Vec::new();
73
74        let host = match Url::parse(url)
75            .ok()
76            .and_then(|u| u.host_str().map(|h| h.to_string()))
77        {
78            Some(h) => h,
79            None => return (findings, errors),
80        };
81
82        // Ensure we probe each host only once in a scan run.
83        if !self.checked_hosts.insert(host.clone()) {
84            return (findings, errors);
85        }
86
87        let baseline = burst_gets(client, url, None, BURST_REQUESTS, &mut errors).await;
88        if baseline.success == 0 && baseline.too_many == 0 {
89            if !errors.is_empty() {
90                findings.push(
91                    Finding::new(
92                        url,
93                        "rate_limit/check-failed",
94                        "Rate limit check could not complete",
95                        Severity::Info,
96                        "All burst probe requests failed; unable to determine whether rate limiting is enforced.",
97                        "rate_limit",
98                    )
99                    .with_evidence(format!(
100                        "Host: {host}\nBurst: {BURST_REQUESTS}\nRequest errors: {}",
101                        errors.len()
102                    ))
103                    .with_remediation(
104                        "Verify network reachability and retry the scan to evaluate rate-limit controls.",
105                    ),
106                );
107            }
108            return (findings, errors);
109        }
110
111        if baseline.too_many > 0 {
112            if !baseline.saw_retry_after {
113                findings.push(
114                    Finding::new(
115                        url,
116                        "rate_limit/missing-retry-after",
117                        "Rate limiting without Retry-After hint",
118                        Severity::Low,
119                        "Endpoint responded with HTTP 429 but did not include Retry-After.",
120                        "rate_limit",
121                    )
122                    .with_evidence(format!(
123                        "Host: {host}\nBurst: {BURST_REQUESTS}\n429s: {}\nStatuses: {}",
124                        baseline.too_many,
125                        compact_statuses(&baseline.statuses)
126                    ))
127                    .with_remediation(
128                        "Include Retry-After with 429 responses to guide compliant client backoff.",
129                    ),
130                );
131            }
132
133            let spoof_ip = random_publicish_ipv4();
134            let bypass_headers = vec![
135                ("X-Forwarded-For".to_string(), spoof_ip.clone()),
136                ("X-Real-IP".to_string(), spoof_ip.clone()),
137                (
138                    "Forwarded".to_string(),
139                    format!("for={spoof_ip};proto=https"),
140                ),
141            ];
142            let bypass = burst_gets(
143                client,
144                url,
145                Some(&bypass_headers),
146                BYPASS_REQUESTS,
147                &mut errors,
148            )
149            .await;
150
151            if bypass.success > 0 && bypass.too_many == 0 {
152                findings.push(
153                    Finding::new(
154                        url,
155                        "rate_limit/ip-header-bypass",
156                        "Rate limit may be bypassed via client IP headers",
157                        Severity::High,
158                        "Baseline burst hit HTTP 429, but requests with spoofed IP headers succeeded.",
159                        "rate_limit",
160                    )
161                    .with_evidence(format!(
162                        "Host: {host}\nBaseline burst: {BURST_REQUESTS}, 429s: {}\nBypass burst: {BYPASS_REQUESTS}, 429s: {}, successes: {}",
163                        baseline.too_many,
164                        bypass.too_many,
165                        bypass.success
166                    ))
167                    .with_remediation(
168                        "Do not trust client-controlled IP headers unless set by trusted proxies; enforce limits on canonical client identity.",
169                    ),
170                );
171            }
172
173            return (findings, errors);
174        }
175
176        if baseline.success > 0 && !baseline.saw_rate_limit_headers {
177            findings.push(
178                Finding::new(
179                    url,
180                    "rate_limit/not-detected",
181                    "No rate limiting detected in burst probe",
182                    Severity::Low,
183                    "A controlled burst did not trigger 429 and no rate-limit headers were observed.",
184                    "rate_limit",
185                )
186                .with_evidence(format!(
187                    "Host: {host}\nBurst: {BURST_REQUESTS}\n429s: 0\nStatuses: {}",
188                    compact_statuses(&baseline.statuses)
189                ))
190                .with_remediation(
191                    "Apply endpoint-level rate limits and emit standard rate-limit headers and 429 responses when thresholds are exceeded.",
192                ),
193            );
194        }
195
196        (findings, errors)
197    }
198}
199
200async fn burst_gets(
201    client: &HttpClient,
202    url: &str,
203    headers: Option<&[(String, String)]>,
204    count: usize,
205    errors: &mut Vec<CapturedError>,
206) -> BurstStats {
207    let mut stats = BurstStats::default();
208    let probe = BurstProbe::new(count, headers.map(|h| h.to_vec()));
209    let responses = probe.execute(client, url).await;
210
211    for response in collect_results(responses, errors) {
212        update_stats(&mut stats, &response);
213    }
214
215    stats
216}
217
218fn update_stats(stats: &mut BurstStats, resp: &HttpResponse) {
219    *stats.statuses.entry(resp.status).or_insert(0) += 1;
220
221    if resp.status == 429 {
222        stats.too_many += 1;
223    } else if resp.status < 400 {
224        stats.success += 1;
225    }
226
227    if has_rate_limit_headers(&resp.headers) {
228        stats.saw_rate_limit_headers = true;
229    }
230    if resp.header("retry-after").is_some() {
231        stats.saw_retry_after = true;
232    }
233}
234
235fn has_rate_limit_headers(headers: &HashMap<String, String>) -> bool {
236    const KEYS: &[&str] = &[
237        "x-ratelimit-limit",
238        "x-ratelimit-remaining",
239        "x-ratelimit-reset",
240        "ratelimit-limit",
241        "ratelimit-remaining",
242        "ratelimit-reset",
243    ];
244
245    KEYS.iter().any(|k| headers.contains_key(*k))
246}
247
248fn compact_statuses(statuses: &HashMap<u16, usize>) -> String {
249    let mut parts = statuses
250        .iter()
251        .map(|(status, count)| format!("{status}:{count}"))
252        .collect::<Vec<_>>();
253    parts.sort();
254    parts.join(", ")
255}