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 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}