Rust后端项目里,外部输入数据是所有HTTP请求参数、请求体、路径参数、查询字符串、Header、Cookie、WebSocket消息、文件上传内容、第三方回调数据的统称。只要数据来自进程边界之外,就必须默认它是恶意的、畸形的、超长的、类型不符的、编码异常的。安全校验不是某个中间件单独完成的事,而是从反序列化、长度限制、格式约束、业务规则到存储编码的完整链路。Rust的类型系统能帮你挡住一部分问题,但挡不住所有问题,尤其是当serde在反序列化阶段过于宽容时,后续校验会变得非常被动。

先看一个最容易被忽略的入口:serde反序列化。很多项目直接对请求体做serde_json::from_str::<T>或serde_urlencoded::from_str::<T>,然后拿到结构体就进入业务逻辑。问题在于serde默认对未知字段、缺失字段、类型不匹配的处理方式取决于结构体定义。如果结构体字段是Option<T>,缺失字段会被静默填充为None;如果字段是String,传入数字在某些格式下会报错,但在另一些格式下可能被强制转换;如果结构体带有#[serde(default)],整个结构体甚至可以在空对象下构造出来。攻击者可以利用这些宽容行为绕过前端校验,直接向后端发送缺失关键字段或字段类型异常的请求。正确的做法是在反序列化之后立即做字段级校验,或者使用带校验能力的反序列化方案,例如validator crate配合自定义提取器,在提取阶段就拒绝不合法数据。

请求体大小限制必须前置

无论使用actix-web、axum还是warp,都应该在读取请求体之前设置最大字节数限制。默认情况下很多框架不会限制请求体大小,攻击者可以发送超大JSON或超大表单把内存打满。actix-web可以通过web::JsonConfig::default().limit(1024 * 1024)来限制JSON提取器,axum则需要自定义提取器或在中间件层检查Content-Length并配合Bytes::limit。注意Content-Length头本身可以被伪造,所以不能只依赖它,必须在流式读取时做硬限制。对于multipart文件上传,限制单文件大小和总请求体大小同样重要,否则磁盘或内存会被耗尽。

字符串字段的规范化与长度校验

外部输入的字符串字段不能只检查非空。一个看似正常的用户名可能包含控制字符、零宽字符、双向文本控制符,或者超长到导致数据库索引膨胀。对于每个字符串字段,应该明确其最小长度、最大长度、允许的字符集。例如用户名只允许字母数字和下划线,长度在3到32之间;邮箱需要做格式校验并限制长度在254以内;显示名称需要过滤控制字符和零宽字符。Rust里可以用validator crate的length、email、regex等注解,但要注意regex校验的性能,避免使用灾难性回溯的模式。更严格的做法是定义newtype包装类型,例如Username(String),在其构造函数里做全部校验,业务代码只接受Username而不是裸String,这样类型系统就能强制保证校验被调用。

数字和枚举的边界校验

外部输入的数字字段如果直接反序列化成u32、i64等类型,负数传给无符号类型会在反序列化阶段报错,但超大正数传给有符号类型也可能溢出。更隐蔽的问题是浮点数接受NaN和Infinity,这两个值在JSON标准里不合法,但某些解析器会放行。如果业务逻辑把NaN存进数据库或用于比较,会产生难以追踪的错误。对于枚举字段,serde默认对未知枚举值报错,这是好事,但如果枚举被标注了#[serde(other)],未知值会被归入other分支,攻击者可以借此探测后端行为。建议对数字字段使用#[serde(try_from = "i32")]配合自定义校验,或者用validator的range注解。对于分页参数,page应该限制在1到某个合理上限,page_size应该限制在1到100之间,防止攻击者请求超大分页拖垮数据库。

路径参数和查询字符串的独立校验

路径参数和查询字符串与请求体不同,它们通常以字符串形式进入handler,需要手动解析。例如/users/{id}中的id如果直接parse::<i32>(),解析失败返回400即可,但如果id是字符串类型的业务标识,就需要做长度和字符集校验。查询字符串里的数组参数、布尔参数、可选参数都要逐一检查。特别注意查询字符串里重复出现的参数,例如?tag=a&tag=b,不同框架处理方式不同,有的取第一个,有的取最后一个,有的收集成数组。如果业务逻辑假设只有一个值,攻击者可以通过重复参数制造歧义。建议在提取阶段明确处理重复参数的策略,要么拒绝,要么收集后校验每个元素。

Header和Cookie的校验不能遗漏

Header里的Content-Type、Accept、Authorization、X-Requested-With等都可能被伪造或注入。特别是Content-Type,如果后端根据它来决定解析方式,攻击者可以发送Content-Type: application/json但请求体是表单格式,或者反过来。Cookie里的会话标识、CSRF token、用户偏好设置都需要做格式校验和长度限制。一个常见的漏洞是Cookie里的用户ID被直接用于数据库查询,攻击者修改Cookie就能越权访问。Cookie值应该像其他外部输入一样被当作不可信数据处理,做类型转换和业务校验。

业务规则校验必须与安全校验分离

安全校验解决的是数据格式、类型、长度、字符集、范围等基础问题,业务规则校验解决的是数据之间的一致性、权限归属、状态流转等业务问题。两者不能混在一起,但都必须做。例如创建订单接口,安全校验保证商品ID是正整数、数量在1到999之间、收货地址长度不超过200且不含控制字符;业务校验保证商品存在、库存充足、用户有购买权限、收货地址属于当前用户。如果只做安全校验不做业务校验,攻击者可以用合法格式的数据越权操作;如果只做业务校验不做安全校验,畸形数据可能触发底层库的未定义行为或panic。

输出编码与存储编码同样重要

校验外部输入不仅是为了防止攻击,也是为了保护下游系统。如果用户输入的内容最终会渲染到HTML页面,即使存储时做了校验,输出时也必须做HTML转义,防止存储型XSS。如果用户输入会拼接到SQL语句,即使做了长度校验,也必须使用参数化查询,防止SQL注入。如果用户输入会作为命令行参数传递给子进程,必须做参数数组化处理,防止命令注入。Rust的std::process::Command默认不经过shell,这已经避免了一部分命令注入,但如果代码里手动拼接字符串再传给sh -c,问题依然存在。存储层面,用户输入写入数据库前应该根据字段类型做编码或转义,读取出来后再根据使用场景做相应处理。

统一校验中间件与提取器模式

在Rust Web框架里,最有效的做法是定义统一的校验提取器。以axum为例,可以定义一个ValidatedJson<T>提取器,内部先做请求体大小限制,再反序列化成T,然后调用T::validate()方法,任何校验失败都返回统一的400响应,包含具体的字段错误信息。这样每个handler只需要声明ValidatedJson<CreateUserRequest>,不需要重复写校验代码。对于路径参数和查询字符串,可以定义ValidatedQuery<T>和ValidatedPath<T>。对于Header和Cookie,可以定义ValidatedHeaders和ValidatedCookies。这种模式把校验逻辑集中起来,避免遗漏,也方便单元测试。

use axum::{
    extract::{FromRequest, rejection::JsonRejection},
    http::Request,
    response::{IntoResponse, Response},
    Json,
};
use serde::de::DeserializeOwned;
use validator::Validate;

pub struct ValidatedJson<T>(pub T);

impl<T, S> FromRequest<S> for ValidatedJson<T>
where
    T: DeserializeOwned + Validate,
    S: Send + Sync,
{
    type Rejection = Response;

    async fn from_request(req: Request<S>, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state)
            .await
            .map_err(|e: JsonRejection| e.into_response())?;
        value.validate().map_err(|e| {
            (
                axum::http::StatusCode::BAD_REQUEST,
                Json(serde_json::json!({
                    "error": "validation_failed",
                    "details": e.to_string()
                })),
            )
                .into_response()
        })?;
        Ok(ValidatedJson(value))
    }
}

这段代码展示了axum下统一校验提取器的基本实现。它先走框架自带的JSON提取器,这一步已经处理了请求体大小限制和JSON语法错误,然后调用validate()做字段级校验。任何校验失败都返回400和统一的错误结构。这种模式可以推广到其他框架和其他输入来源。

第三方回调数据的特殊处理

支付回调、Webhook回调、OAuth回调等第三方数据虽然来自可信的第三方,但传输过程中可能被篡改,或者第三方本身被攻破。这类数据必须做签名验证,而不仅仅是格式校验。例如支付回调通常带有签名参数,后端需要用预先共享的密钥重新计算签名并比对。Webhook回调可能带有HMAC头,需要验证其正确性。OAuth回调的state参数必须与发起请求时生成的state一致,防止CSRF。签名验证失败的数据应该直接丢弃,不进入业务逻辑。签名验证通过的数据仍然要做格式校验,因为第三方也可能发送不符合预期的字段。

错误信息不能泄露内部细节

校验失败时返回的错误信息要平衡可调试性和安全性。返回给客户端的错误信息应该明确告知哪个字段不合法、期望的格式是什么,但不能泄露数据库结构、内部类名、文件路径、堆栈信息。例如返回username must be 3-32 characters是合理的,返回error at src/models/user.rs:42就是危险的。Rust的panic信息默认会包含文件路径和行号,如果panic被框架捕获并返回给客户端,就会泄露源码结构。生产环境应该设置自定义panic hook,记录详细日志但返回通用的500错误。

测试驱动安全校验

安全校验逻辑必须用单元测试覆盖,包括正常输入、边界输入、超长输入、类型错误、未知字段、缺失字段、特殊字符、Unicode字符、空值、负数、超大数字、重复参数、伪造Header、篡改Cookie等场景。每个外部输入字段至少要有正常、边界、非法三类测试用例。集成测试要覆盖完整的请求链路,确保校验中间件在handler之前生效。模糊测试可以用cargo-fuzz对反序列化和校验函数做随机输入测试,发现panic或未预期行为。安全校验的测试不能只测happy path,攻击者不会按正常方式使用接口。

性能与安全的平衡

安全校验会带来性能开销,尤其是正则校验和大量字符串操作。但安全校验的性能损耗通常远小于数据库查询和网络传输,不应该为了省几微秒而省略校验。如果某些字段的正则校验确实成为瓶颈,可以优化正则模式,避免回溯,或者改用手写的字符遍历校验。对于高频接口,可以缓存校验结果,但要注意缓存键必须包含完整的输入数据,不能只缓存部分字段。请求体大小限制和连接超时设置也能间接提升性能,因为它们防止了慢速攻击和资源耗尽。

Rust后端的安全校验本质上是在类型系统和运行时检查之间建立多层防线。类型系统保证数据在编译期就符合基本形状,serde配置保证反序列化阶段拒绝明显非法的数据,字段级校验保证数据符合业务约束,业务规则校验保证数据操作合法,输出编码保证数据在离开后端时不会伤害其他系统。每一层都不能省略,因为攻击者会找到最薄弱的环节。把校验逻辑集中在提取器和newtype类型里,可以让安全约束在代码库中显式化、可测试、可维护,而不是散落在各个handler里靠开发者自觉。