Skip to main content

security_rust/data/
csv_injection.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/// 强信号:公式必须落在**单元格**里(行首或字段分隔符之后)且形态本身指向
8/// 「能执行 / 能外带」——`=cmd|`、`@SUM(`、`DDE`。
9/// 各分支合成一条 alternation(原来 N 条要各扫一遍)。flags **不一致**,
10/// 故每条都收在自己的 `(?…:…)` 组里——裸 `(?im)` 会顺延到后面的分支。
11static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(
13        &[
14            // 字段分隔符之后的 `=`:`admin,=1+1`、`x;=HYPERLINK(...)`、`\t=1`、
15            // `,"=cmd|..."`。两条收紧:
16            //   - 只认 `=`——`,`/`;`/`\t` 后的 `+`/`-`/`@` 在散文里太常见
17            //     (`1, -2, -3`、`me, @alice`);
18            //   - `=` 后面必须紧跟非空白——`key\t= value` 这种制表符对齐的配置
19            //     不是公式,公式里 `=` 后面是操作数。
20            r#"(?m:[,;\t][ \t]*"?[ \t]*=[^ \t])"#,
21            r"|(?im:^\s*DDE)",
22            r"|(?im:^\s*cmd\s*\|)",
23            r"|(?im:^\s*@SUM\s*\()",
24        ]
25        .concat(),
26    )
27    .unwrap()
28});
29
30/// 弱信号:行首前缀**后面必须跟内容**。裸前缀在正常文本里遍地都是——MIME 分隔行
31/// `--boundary123--`(每个 multipart 请求体都有若干条)、markdown 列表项 `- item`、
32/// 分隔线 / YAML front matter 的 `---`、setext 标题下划线 `=====`、代码里的 `++i`
33/// 全是这个形态。于是前缀后既不能是另一个 `=`/`+`/`-`(挡掉 `--`、`===`、`++i`),
34/// 也不能是空白(挡掉 `- item`)——实测本仓库 361 个文本文件命中数从 214 降到 0。
35///
36/// `@` 整个移出粗粒度层:公式里的 `@` 只会以 `@SUM(` 这类函数形态出现,本文件下一条
37/// `^\s*@SUM\(` 与 formula_injection 的函数名录已完整覆盖,而 `@media` / `@import`
38/// 这类 CSS at-rule 在样式表里遍地都是。收紧后仍报 Low 而不是 Medium:
39/// `-2 degrees` 这种散文里的负数(前缀 + 数字,与 `-2+3` 字节同形)无法用正则与
40/// 单元格里的公式区分,单条不足以触发拒绝。
41static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
42    // 制表符与回车**不是**公式起始——它们是分隔符:`"\t\n\r"`(制表符分隔的空行)
43    // 和 `"a\r\n\r\nb"`(CRLF 空行)都曾被这一条判成数据注入。
44    Regex::new(r"(?m)^[=+\-][^=+\-\s]").unwrap()
45});
46
47pub struct CsvInjectionDetector;
48
49impl Detector for CsvInjectionDetector {
50    fn name(&self) -> &'static str {
51        "csv_injection"
52    }
53
54    fn detect(&self, input: &str) -> Option<DetectionResult> {
55        regex_detect(
56            std::slice::from_ref(&*STRONG_PATTERNS),
57            self.name(),
58            AttackCategory::Data,
59            Severity::Medium,
60            "CSV formula injection detected",
61            input,
62        )
63        .or_else(|| {
64            regex_detect(
65                std::slice::from_ref(&*WEAK_PATTERNS),
66                self.name(),
67                AttackCategory::Data,
68                Severity::Low,
69                "CSV formula prefix present (weak signal)",
70                input,
71            )
72        })
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::test_helpers::{assert_clean, assert_detected};
80
81    fn det() -> CsvInjectionDetector {
82        CsvInjectionDetector
83    }
84
85    fn assert_hit_at(input: &str, severity: Severity) {
86        assert_detected(&det(), input, AttackCategory::Data, severity);
87    }
88
89    #[test]
90    fn name_returns_attack_type() {
91        assert_eq!(CsvInjectionDetector.name(), "csv_injection");
92    }
93
94    #[test]
95    fn detects_formula_prefixes() {
96        // 强信号:落在单元格边界(行首或分隔符之后)且形态指向执行/外带,Medium
97        for payload in [
98            "@SUM(1+1)*cmd",
99            "\t=1",
100            "列1\t=1",
101            "admin,=1+1",
102            "x;=HYPERLINK(\"http://evil.com\")",
103            ",\"=cmd|' /C calc'!A0\"",
104            "DDE;cmd",
105            "cmd|' /C calc'!A0",
106        ] {
107            assert_hit_at(payload, Severity::Medium);
108        }
109        // 弱信号:只有行首前缀本身,Low——仍检出,但单条不越过拒绝线
110        for payload in ["=cmd|' /C calc'!A0", "+1+1", "-2+3"] {
111            assert_hit_at(payload, Severity::Low);
112        }
113    }
114
115    /// 弱信号里剩下的形态:前缀 + 内容(`-2`、`=1`)。它仍检出,但只有 Low——
116    /// 散文里的负数与单元格里的公式字节同形,单条不足以拒绝。
117    #[test]
118    fn prefix_followed_by_content_is_low() {
119        for input in [
120            "-2+3",
121            "-2 degrees overnight",
122            "=1+1",
123            "+1 more item",
124        ] {
125            assert_hit_at(input, Severity::Low);
126        }
127    }
128
129    /// 裸前缀**不是**公式。MIME 分隔行是每一个 multipart 请求体都带的字节,
130    /// markdown 列表项 / 分隔线 / setext 下划线 / `++i` 同理。这些必须
131    /// **干净**(不是 Low)——收紧后的形态要求前缀后面跟内容,它们连前缀都不算。
132    /// 实测本仓库 361 个文本文件:收紧前 214 个命中,收紧后 0 个。
133    #[test]
134    fn bare_prefixes_are_clean() {
135        for input in [
136            "--x--",
137            "--boundary123--",
138            "----------------------------d74496d66958873e",
139            "- item",
140            "--",
141            "---",
142            "=====",
143            "++i;",
144            "@media (max-width: 640px) { .card { flex-direction: column; } }",
145            "## Deployment\n\n- item one\n- item two",
146            "Title\n=====",
147        ] {
148            assert_clean(&det(), input);
149        }
150    }
151
152    /// 真实 multipart 请求体:分隔行是 `--` + boundary,每段之间都有一条。
153    /// mail_header 的 `Content-Type: multipart` / `boundary=` 已删除后,
154    /// 这一条曾是本库最后一个会在正常上传上报的检测器。
155    #[test]
156    fn real_multipart_upload_is_clean() {
157        let body = "POST /upload HTTP/1.1\r\n\
158Host: example.com\r\n\
159User-Agent: curl/8.5.0\r\n\
160Content-Type: multipart/form-data; boundary=------------------------d74496d66958873e\r\n\
161Content-Length: 4096\r\n\
162\r\n\
163----------------------------d74496d66958873e\r\n\
164Content-Disposition: form-data; name=\"file\"; filename=\"report.pdf\"\r\n\
165Content-Type: application/pdf\r\n\
166\r\n\
167%PDF-1.4 binary payload\r\n\
168----------------------------d74496d66958873e\r\n\
169Content-Disposition: form-data; name=\"note\"\r\n\
170\r\n\
171- reproduced on staging\r\n\
172----------------------------d74496d66958873e--\r\n";
173        assert_clean(&det(), body);
174    }
175
176    #[test]
177    fn ignores_benign_inputs() {
178        for input in [
179            "Hello, this is a normal text input.",
180            "a=1+1",
181            "SUM(1+1)",
182            "cmd /C calc",
183            "not a formula",
184            // 空白字符是分隔符,不是公式起始符
185            "\t\n\r",
186            "a\tb",
187            "列1\t列2\t列3",
188            "hello world",
189            "line one\r\n\r\nline two",
190            "a, b, c",
191            "2024-01-01",
192            // 逗号后的 `+`/`-`/`@` 是散文形态,不认
193            "1, -2, -3",
194            "me, @alice",
195        ] {
196            assert!(
197                CsvInjectionDetector.detect(input).is_none(),
198                "false positive: {:?}",
199                input
200            );
201        }
202    }
203
204    #[test]
205    fn edge_cases() {
206        assert!(CsvInjectionDetector.detect("").is_none());
207        assert!(CsvInjectionDetector.detect("   ").is_none());
208        assert!(CsvInjectionDetector.detect("=cmd|' /C calc'!A0").is_none()); // fullwidth equals
209    }
210}