Skip to main content

api_scanner/scanner/
mass_assignment.rs

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
17/// Detects potential mass-assignment vulnerabilities by injecting privileged fields.
18///
19/// # How It Works
20/// 1. Baseline `GET` captures currently elevated sensitive fields.
21/// 2. Active probe sends `POST` with privileged fields.
22/// 3. Confirmation `GET` checks whether reflected fields persisted as newly elevated state.
23///
24/// # Findings
25/// - `mass_assignment/reflected-fields` (`MEDIUM`): privileged fields reflected in response.
26/// - `mass_assignment/persisted-state-change` (`HIGH`): reflected fields also persisted in state.
27/// - `mass_assignment/dry-run` (`INFO`): scanner configured to report planned probe only.
28pub 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    // Always keep canonical probe fields in the payload.
317    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}