Skip to main content

security_rust/data/
prototype_pollution.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// 强信号:污染必须带**写入**形态。`__proto__` 作为键(`"__proto__":`、
8/// `[__proto__]`)或被赋值(`__proto__ = x`)才是污染;裸 token 不是。
9static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
10    // 九条分支的 flags 完全一致(都是 `(?i)`),提到最前面即可。
11    Regex::new(
12        &[
13            // 键形态:JSON/YAML 的 `"__proto__":`、方括号的 `[__proto__]` / `o[__proto__][x]`
14            r#"(?i)__proto__(?:["'\[\]])"#,
15            // 赋值形态。`=` 后跟 `[ \t]*[^=\s]` 排除 `===` / `==` 比较——
16            // `obj.__proto__ === Array.prototype` 是读取原型,不是污染;
17            // 而 `obj.__proto__ = {}` / `?__proto__=1` 是写入。
18            r"|__proto__\s*=[ \t]*[^=\s]",
19            r"|constructor\[",
20            r"|constructor\.prototype",
21            r"|__defineGetter__",
22            r"|__defineSetter__",
23            r"|__lookupGetter__",
24            r"|__lookupSetter__",
25            r"|hasOwnProperty\[",
26        ]
27        .concat(),
28    )
29    .unwrap()
30});
31
32/// 弱信号:裸 `__proto__`。任何碰原型链的 JS 都会出现它——`const p = obj.__proto__;`
33/// 是语言本身的读法——但它也确实是污染的载体(`obj.__proto__.x = 1` 这条路径
34/// 上面的强形态没覆盖)。报 Low:单条不足以拒绝。
35static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)__proto__").unwrap());
36
37pub struct PrototypePollutionDetector;
38
39impl Detector for PrototypePollutionDetector {
40    fn name(&self) -> &'static str {
41        "prototype_pollution"
42    }
43
44    fn detect(&self, input: &str) -> Option<DetectionResult> {
45        regex_detect(
46            std::slice::from_ref(&*STRONG_PATTERNS),
47            self.name(),
48            AttackCategory::Data,
49            Severity::High,
50            "JavaScript prototype pollution detected",
51            input,
52        )
53        .or_else(|| {
54            regex_detect(
55                std::slice::from_ref(&*WEAK_PATTERNS),
56                self.name(),
57                AttackCategory::Data,
58                Severity::Low,
59                "__proto__ referenced (weak signal)",
60                input,
61            )
62        })
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn name_returns_attack_type() {
72        assert_eq!(PrototypePollutionDetector.name(), "prototype_pollution");
73    }
74
75    #[test]
76    fn detects_proto_and_constructor_payloads() {
77        for payload in [
78            r#"{"__proto__": {"isAdmin": true}}"#,
79            r#"{"__proto__": {"polluted": true}}"#,
80            "obj.constructor.prototype.isAdmin = true",
81            "a[constructor[0]]",
82            "o[__proto__][isAdmin]",
83        ] {
84            let r = PrototypePollutionDetector
85                .detect(payload)
86                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
87            assert_eq!(r.attack_type, "prototype_pollution");
88            assert_eq!(r.category, AttackCategory::Data);
89            assert_eq!(r.severity, Severity::High);
90            assert!(
91                !r.matched_pattern.is_empty(),
92                "matched_pattern empty for {:?}",
93                payload
94            );
95            assert!(
96                r.offset <= payload.len(),
97                "offset out of range for {:?}",
98                payload
99            );
100        }
101    }
102
103    #[test]
104    fn detects_legacy_getter_setter_apis() {
105        for payload in [
106            "__defineGetter__('x', fn)",
107            "__defineSetter__('x', fn)",
108            "__lookupGetter__('x')",
109            "__lookupSetter__('x')",
110            "hasOwnProperty['isAdmin']",
111        ] {
112            let r = PrototypePollutionDetector
113                .detect(payload)
114                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
115            assert!(
116                !r.matched_pattern.is_empty(),
117                "matched_pattern empty for {:?}",
118                payload
119            );
120            assert!(
121                r.offset <= payload.len(),
122                "offset out of range for {:?}",
123                payload
124            );
125        }
126    }
127
128    /// 裸 `__proto__` 是语言本身的读法,不是污染;只有键/赋值形态才写成 High。
129    /// 攻击方向必须仍然 High:降档不能靠「把检测删掉」达成。
130    #[test]
131    fn bare_proto_reference_is_low_key_shape_is_high() {
132        for payload in [
133            r#"{"__proto__": {"isAdmin": true}}"#,
134            r#"{"__proto__":{"polluted":1}}"#,
135            "o[__proto__][isAdmin]",
136            "obj.__proto__ = {}",
137            "?__proto__[x]=1",
138        ] {
139            let r = PrototypePollutionDetector
140                .detect(payload)
141                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
142            assert_eq!(r.severity, Severity::High, "payload {:?}", payload);
143        }
144        // 读原型 + 文档里的裸 token:仍检出(弱信号是信号),但只有 Low
145        for input in [
146            "const p = obj.__proto__;",
147            "if (obj.__proto__ === Array.prototype) { init(); }",
148            "| **prototype_pollution** | `__proto__` 原型链污染 | High |",
149        ] {
150            let r = PrototypePollutionDetector
151                .detect(input)
152                .unwrap_or_else(|| panic!("expected weak detection for {:?}", input));
153            assert_eq!(r.severity, Severity::Low, "input {:?}", input);
154        }
155    }
156
157    #[test]
158    fn ignores_benign_inputs() {
159        for input in [
160            "Hello, this is a normal text input.",
161            "constructor",
162            "hasOwnProperty",
163            "proto",
164            "the prototype chain is a concept",
165        ] {
166            assert!(
167                PrototypePollutionDetector.detect(input).is_none(),
168                "false positive: {:?}",
169                input
170            );
171        }
172    }
173
174    #[test]
175    fn edge_cases() {
176        assert!(PrototypePollutionDetector.detect("").is_none());
177        assert!(PrototypePollutionDetector.detect("   ").is_none());
178        assert!(PrototypePollutionDetector.detect("__proto__").is_none()); // fullwidth underscores
179        assert!(PrototypePollutionDetector.detect("Прототип").is_none());
180    }
181}