1use async_trait::async_trait;
2use serde_json::json;
3use std::collections::HashSet;
4use url::Url;
5
6use crate::{
7 config::Config,
8 error::CapturedError,
9 http_client::HttpClient,
10 reports::{Finding, Severity},
11};
12
13use super::{
14 common::finding_builder::FindingBuilder, common::http_utils::parse_json_response, Scanner,
15};
16
17pub struct MassAssignmentScanner;
29
30impl MassAssignmentScanner {
31 pub fn new(_config: &Config) -> Self {
32 Self
33 }
34}
35
36static MUTATION_HINTS: &[&str] = &[
37 "users",
38 "user",
39 "account",
40 "profile",
41 "admin",
42 "settings",
43 "roles",
44 "permissions",
45];
46
47#[async_trait]
48impl Scanner for MassAssignmentScanner {
49 fn name(&self) -> &'static str {
50 "mass_assignment"
51 }
52
53 async fn scan(
54 &self,
55 url: &str,
56 client: &HttpClient,
57 config: &Config,
58 ) -> (Vec<Finding>, Vec<CapturedError>) {
59 if should_skip_scan(config, url) {
60 return (Vec::new(), Vec::new());
61 }
62
63 if config.dry_run {
64 let payload = create_probe_payload(&[]);
65 return (vec![create_dry_run_finding(url, &payload)], Vec::new());
66 }
67
68 let mut findings = Vec::new();
69 let mut errors = Vec::new();
70 let mut candidate_probe_fields = Vec::new();
71
72 let baseline_elevated = match fetch_baseline_observation(client, url, "baseline_get").await
73 {
74 Ok((fields, candidates)) => {
75 candidate_probe_fields = candidates;
76 Some(fields)
77 }
78 Err(e) => {
79 errors.push(e);
80 None
81 }
82 };
83 let payload = create_probe_payload(&candidate_probe_fields);
84
85 let resp = match client
86 .post_json(url, &payload)
87 .await
88 .map_err(|e| annotate_error(e, "probe_post"))
89 {
90 Ok(r) => r,
91 Err(e) => {
92 errors.push(e);
93 return (findings, errors);
94 }
95 };
96
97 if should_skip_response_status(resp.status) {
98 return (findings, errors);
99 }
100
101 let parsed_post = match parse_json_response(&resp) {
102 Some(v) => v,
103 None => return (findings, errors),
104 };
105
106 let reflected = reflected_probe_fields_from_value(&parsed_post);
107 if reflected.is_empty() {
108 return (findings, errors);
109 }
110
111 let mut confirmed_fields = Vec::new();
112 let mut confirmation_failed = false;
113 if let Some(before_elevated) = baseline_elevated.as_ref() {
114 match fetch_elevated_fields(client, url, "confirm_get").await {
115 Ok(after_elevated) => {
116 confirmed_fields =
117 compute_newly_elevated_fields(before_elevated, after_elevated, &reflected);
118 }
119 Err(e) => {
120 confirmation_failed = true;
121 errors.push(e);
122 }
123 }
124 }
125
126 if !confirmed_fields.is_empty() {
127 confirmed_fields.sort_unstable();
128 findings.push(create_mass_assignment_finding(
129 url,
130 resp.status,
131 &reflected,
132 Some(&confirmed_fields),
133 false,
134 ));
135 } else {
136 findings.push(create_mass_assignment_finding(
137 url,
138 resp.status,
139 &reflected,
140 None,
141 confirmation_failed,
142 ));
143 }
144
145 (findings, errors)
146 }
147}
148
149fn should_skip_scan(config: &Config, url: &str) -> bool {
150 !config.active_checks || !is_likely_mutation_target(url)
151}
152
153fn is_likely_mutation_target(url: &str) -> bool {
154 let parsed = match Url::parse(url) {
155 Ok(u) => u,
156 Err(_) => return false,
157 };
158
159 let path = parsed.path().trim_end_matches('/');
160 let Some(last_segment) = path.rsplit('/').next() else {
161 return false;
162 };
163 if last_segment.is_empty() {
164 return false;
165 }
166 let last_segment_l = last_segment.to_ascii_lowercase();
167 MUTATION_HINTS.iter().any(|hint| last_segment_l == *hint)
168}
169
170fn should_skip_response_status(status: u16) -> bool {
171 status >= 400
172}
173
174fn reflected_probe_fields_from_value(value: &serde_json::Value) -> Vec<String> {
175 elevated_fields_from_value(value)
176}
177
178async fn fetch_baseline_observation(
179 client: &HttpClient,
180 url: &str,
181 phase: &'static str,
182) -> Result<(Vec<String>, Vec<String>), CapturedError> {
183 let resp = client
184 .get(url)
185 .await
186 .map_err(|e| annotate_error(e, phase))?;
187
188 if should_skip_response_status(resp.status) {
189 return Ok((Vec::new(), Vec::new()));
190 }
191
192 let Some(parsed) = parse_json_response(&resp) else {
193 return Ok((Vec::new(), Vec::new()));
194 };
195
196 Ok((
197 elevated_fields_from_value(&parsed),
198 sensitive_candidate_fields_from_value(&parsed),
199 ))
200}
201
202async fn fetch_elevated_fields(
203 client: &HttpClient,
204 url: &str,
205 phase: &'static str,
206) -> Result<Vec<String>, CapturedError> {
207 let resp = client
208 .get(url)
209 .await
210 .map_err(|e| annotate_error(e, phase))?;
211
212 if should_skip_response_status(resp.status) {
213 return Ok(Vec::new());
214 }
215
216 let Some(parsed) = parse_json_response(&resp) else {
217 return Ok(Vec::new());
218 };
219
220 Ok(elevated_fields_from_value(&parsed))
221}
222
223fn compute_newly_elevated_fields(
224 before: &[String],
225 after: Vec<String>,
226 reflected: &[String],
227) -> Vec<String> {
228 let before_set: HashSet<String> = before.iter().cloned().collect();
229 let after_set: HashSet<String> = after.into_iter().collect();
230 let reflected_set: HashSet<String> = reflected.iter().cloned().collect();
231
232 let mut confirmed = Vec::new();
233 for field in after_set.difference(&before_set) {
234 if reflected_set.contains(field) {
235 confirmed.push(field.clone());
236 }
237 }
238 confirmed.sort_unstable();
239 confirmed
240}
241
242fn elevated_fields_from_value(parsed: &serde_json::Value) -> Vec<String> {
243 let mut out = HashSet::new();
244 collect_elevated_fields(parsed, &mut out);
245 let mut fields: Vec<String> = out.into_iter().collect();
246 fields.sort_unstable();
247 fields
248}
249
250fn collect_elevated_fields(value: &serde_json::Value, out: &mut HashSet<String>) {
251 match value {
252 serde_json::Value::Object(map) => {
253 for (k, v) in map {
254 if let Some(field) = canonical_sensitive_field(k) {
255 if is_elevated_sensitive_value(&field, v) {
256 out.insert(field);
257 }
258 }
259 collect_elevated_fields(v, out);
260 }
261 }
262 serde_json::Value::Array(arr) => {
263 for item in arr {
264 collect_elevated_fields(item, out);
265 }
266 }
267 _ => {}
268 }
269}
270
271fn canonical_sensitive_field(key: &str) -> Option<String> {
272 if key_matches_normalized(key, "isadmin") || key_matches_normalized(key, "isadministrator") {
273 return Some("is_admin".to_string());
274 }
275
276 if key_matches_normalized(key, "role")
277 || key_matches_normalized(key, "roles")
278 || key_matches_normalized(key, "userrole")
279 || key_matches_normalized(key, "accountrole")
280 {
281 return Some("role".to_string());
282 }
283
284 if key_matches_normalized(key, "permission")
285 || key_matches_normalized(key, "permissions")
286 || key_matches_normalized(key, "scope")
287 || key_matches_normalized(key, "scopes")
288 {
289 return Some("permissions".to_string());
290 }
291
292 let normalized = normalize_key(key);
293 if is_sensitive_key_hint(&normalized) {
294 return Some(normalized);
295 }
296
297 None
298}
299
300fn is_elevated_sensitive_value(field: &str, value: &serde_json::Value) -> bool {
301 match sensitive_kind(field) {
302 SensitiveKind::BooleanFlag => match value {
303 serde_json::Value::Bool(true) => true,
304 serde_json::Value::Number(n) => n.as_i64() == Some(1),
305 serde_json::Value::String(s) => is_truthy_string(s),
306 _ => false,
307 },
308 SensitiveKind::RoleLike => value_is_privileged_role(value),
309 SensitiveKind::PermissionLike => value_has_privileged_permission(value),
310 }
311}
312
313fn sensitive_candidate_fields_from_value(parsed: &serde_json::Value) -> Vec<String> {
314 let mut out = HashSet::new();
315
316 out.insert("is_admin".to_string());
318 out.insert("role".to_string());
319 out.insert("permissions".to_string());
320 collect_sensitive_candidate_fields(parsed, &mut out);
321
322 let mut fields: Vec<String> = out.into_iter().collect();
323 fields.sort_unstable();
324 fields
325}
326
327fn collect_sensitive_candidate_fields(value: &serde_json::Value, out: &mut HashSet<String>) {
328 match value {
329 serde_json::Value::Object(map) => {
330 for (k, v) in map {
331 if is_sensitive_key_hint(&normalize_key(k)) {
332 out.insert(k.clone());
333 }
334 collect_sensitive_candidate_fields(v, out);
335 }
336 }
337 serde_json::Value::Array(arr) => {
338 for item in arr {
339 collect_sensitive_candidate_fields(item, out);
340 }
341 }
342 _ => {}
343 }
344}
345
346fn is_truthy_string(s: &str) -> bool {
347 let token = s.trim();
348 token == "1"
349 || token.eq_ignore_ascii_case("true")
350 || token.eq_ignore_ascii_case("yes")
351 || token.eq_ignore_ascii_case("on")
352 || token.eq_ignore_ascii_case("admin")
353}
354
355fn value_is_privileged_role(value: &serde_json::Value) -> bool {
356 match value {
357 serde_json::Value::String(s) => is_privileged_role_token(s),
358 serde_json::Value::Array(arr) => arr
359 .iter()
360 .filter_map(|item| item.as_str())
361 .any(is_privileged_role_token),
362 _ => false,
363 }
364}
365
366fn value_has_privileged_permission(value: &serde_json::Value) -> bool {
367 match value {
368 serde_json::Value::String(s) => is_privileged_permission_token(s),
369 serde_json::Value::Array(arr) => arr
370 .iter()
371 .filter_map(|item| item.as_str())
372 .any(is_privileged_permission_token),
373 _ => false,
374 }
375}
376
377fn is_privileged_role_token(token: &str) -> bool {
378 let token = token.trim();
379 token.eq_ignore_ascii_case("admin")
380 || token.eq_ignore_ascii_case("superadmin")
381 || token.eq_ignore_ascii_case("superuser")
382 || token.eq_ignore_ascii_case("owner")
383 || token.eq_ignore_ascii_case("root")
384}
385
386fn is_privileged_permission_token(token: &str) -> bool {
387 let token = token.trim();
388 token == "*"
389 || token.eq_ignore_ascii_case("admin")
390 || token.eq_ignore_ascii_case("owner")
391 || token.eq_ignore_ascii_case("root")
392 || token.eq_ignore_ascii_case("all")
393}
394
395fn key_matches_normalized(key: &str, normalized: &str) -> bool {
396 let mut key_iter = key
397 .bytes()
398 .filter(|b| b.is_ascii_alphanumeric())
399 .map(|b| b.to_ascii_lowercase());
400
401 for expected in normalized.bytes() {
402 match key_iter.next() {
403 Some(actual) if actual == expected => {}
404 _ => return false,
405 }
406 }
407
408 key_iter.next().is_none()
409}
410
411fn normalize_key(key: &str) -> String {
412 key.bytes()
413 .filter(|b| b.is_ascii_alphanumeric())
414 .map(|b| (b as char).to_ascii_lowercase())
415 .collect()
416}
417
418fn is_sensitive_key_hint(normalized: &str) -> bool {
419 if normalized.is_empty() {
420 return false;
421 }
422
423 const HINTS: &[&str] = &[
424 "admin",
425 "administrator",
426 "superuser",
427 "superadmin",
428 "owner",
429 "root",
430 "privilege",
431 "permission",
432 "scope",
433 "role",
434 "accesstype",
435 "accesslevel",
436 "authority",
437 "accounttype",
438 "usertype",
439 "entitlement",
440 "isadmin",
441 "isowner",
442 "isroot",
443 "elevated",
444 ];
445
446 HINTS.iter().any(|hint| normalized.contains(hint))
447}
448
449#[derive(Clone, Copy)]
450enum SensitiveKind {
451 BooleanFlag,
452 RoleLike,
453 PermissionLike,
454}
455
456fn sensitive_kind(field: &str) -> SensitiveKind {
457 let normalized = normalize_key(field);
458 if normalized == "permissions"
459 || normalized.contains("permission")
460 || normalized.contains("scope")
461 || normalized.contains("privilege")
462 || normalized.contains("entitlement")
463 {
464 return SensitiveKind::PermissionLike;
465 }
466
467 if normalized == "role"
468 || normalized.contains("role")
469 || normalized.contains("type")
470 || normalized.contains("level")
471 || normalized.contains("group")
472 || normalized.contains("tier")
473 || normalized.contains("class")
474 {
475 return SensitiveKind::RoleLike;
476 }
477
478 SensitiveKind::BooleanFlag
479}
480
481fn probe_value_for_field(field: &str) -> serde_json::Value {
482 match sensitive_kind(field) {
483 SensitiveKind::BooleanFlag => json!(true),
484 SensitiveKind::RoleLike => json!("admin"),
485 SensitiveKind::PermissionLike => json!(["*"]),
486 }
487}
488
489fn create_mass_assignment_finding(
490 url: &str,
491 status: u16,
492 reflected: &[String],
493 confirmed: Option<&[String]>,
494 confirmation_failed: bool,
495) -> Finding {
496 if let Some(confirmed_fields) = confirmed {
497 FindingBuilder::new(url, "mass_assignment")
498 .check("mass_assignment/persisted-state-change")
499 .title("Potential persisted privilege/state change")
500 .severity(Severity::High)
501 .detail("Sensitive fields appear newly elevated after crafted field injection.")
502 .build()
503 .with_evidence(format!(
504 "POST {url}\nStatus: {status}\nReflected fields: {}\nNewly elevated after confirm GET: {}",
505 reflected.join(", "),
506 confirmed_fields.join(", ")
507 ))
508 .with_remediation(
509 "Block sensitive fields from client-controlled input and enforce server-side authorization invariants.",
510 )
511 } else {
512 let mut evidence = format!(
513 "POST {url}\nStatus: {status}\nReflected fields: {}",
514 reflected.join(", ")
515 );
516 if confirmation_failed {
517 evidence
518 .push_str("\nNote: Confirmation GET failed; persistence could not be verified.");
519 }
520
521 FindingBuilder::new(url, "mass_assignment")
522 .check("mass_assignment/reflected-fields")
523 .title("Potential mass-assignment via reflected fields")
524 .severity(Severity::Medium)
525 .detail("Response reflected crafted sensitive fields from request payload.")
526 .build()
527 .with_evidence(evidence)
528 .with_remediation(
529 "Use explicit allowlists for writeable fields and reject unexpected attributes server-side.",
530 )
531 }
532}
533
534fn create_probe_payload(candidate_keys: &[String]) -> serde_json::Value {
535 let mut payload = serde_json::Map::new();
536
537 for key in candidate_keys {
538 if key.trim().is_empty() {
539 continue;
540 }
541 payload
542 .entry(key.clone())
543 .or_insert_with(|| probe_value_for_field(key));
544 }
545
546 for key in ["is_admin", "role", "permissions"] {
547 payload
548 .entry(key.to_string())
549 .or_insert_with(|| probe_value_for_field(key));
550 }
551
552 serde_json::Value::Object(payload)
553}
554
555fn create_dry_run_finding(url: &str, payload: &serde_json::Value) -> Finding {
556 FindingBuilder::new(url, "mass_assignment")
557 .check("mass_assignment/dry-run")
558 .title("Mass assignment dry run")
559 .severity(Severity::Info)
560 .detail("Dry-run mode enabled; no mutation probe was sent.")
561 .build()
562 .with_evidence(format!("Would POST payload: {payload}"))
563}
564
565fn annotate_error(mut err: CapturedError, phase: &'static str) -> CapturedError {
566 err.message = format!("{phase}: {}", err.message);
567 err
568}