2. 输入验证的核心原则
输入验证的目标不是“把危险字符删掉”,而是让进入业务逻辑的数据在结构、类型、长度、取值和编码形态上都符合预期。要做到这一点,需要先建立几条稳定的原则:白名单优先、黑名单仅作补充、类型与格式校验、长度与范围约束、规范化与编码处理,以及最小信任边界。它们共同决定了验证策略的选择依据。
白名单与黑名单:允许什么,而不是禁止什么
白名单(allowlist)定义“合法输入必须满足什么条件”,例如用户名只允许字母、数字和下划线,长度 3~20;黑名单(denylist)定义“出现哪些内容就拒绝”,例如过滤 <script>、--、../。两者的根本差异在于:白名单是封闭集合,攻击者难以构造未预料到的变体;黑名单是开放集合,绕过方式几乎无穷。
以登录场景为例,用户名若采用黑名单过滤引号和分号,攻击者仍可能用编码、大小写、Unicode 同形字符或注释符绕过。更稳妥的做法是白名单:^[A-Za-z0-9_]{3,20}$。密码则不应做字符白名单,因为密码需要允许各种符号,此时应做长度约束、禁止空字节、限制最大长度(如 8~128),并配合哈希存储,而不是试图过滤“危险字符”。
搜索场景更能说明问题。搜索关键词通常允许中文、英文、数字、空格和少量标点,若用黑名单过滤 <、>,攻击者可用 URL 编码 %3C 绕过。正确策略是:对搜索词做长度限制(如 1~100 字符)、去除控制字符、在输出到 HTML 时进行上下文转义,而不是在输入阶段穷举危险字符。
数据类型校验:先确定“它是什么”
类型校验是验证的第一道闸门。来自 HTTP 请求的参数本质上都是字符串,因此“类型校验”指的是按业务语义解析并确认其真实类型。例如 age 必须是整数,price 必须是十进制数,is_active 必须是布尔值,user_id 必须是 UUID 或正整数。
实操中应遵循“先解析、再校验、后使用”的顺序。以年龄为例:
raw = request.args.get("age")
if not raw or not raw.isdigit():
reject("age 必须是非负整数")
age = int(raw)
if not (0 <= age <= 150):
reject("age 超出允许范围")
这里 isdigit() 先确认字符串形态,int() 完成解析,范围检查再约束业务含义。若直接 int(raw),遇到 "12abc" 会抛异常,遇到 "1e3" 可能被某些语言解析为浮点,导致后续比较出现意外。类型校验的价值在于把“字符串世界”安全地转换为“业务类型世界”。
长度与范围约束:给数据划定边界
长度约束既防溢出,也防资源耗尽。注册场景中,用户名长度可设为 3~20,邮箱长度可设为 6~254,密码长度可设为 8~128。范围约束则针对数值和集合,例如商品数量 1~999,分页页码 1~1000,排序字段只能取 created_at、price、sales 之一。
长度校验必须在解码之后、业务处理之前进行。若先按字节截断再解码,可能把多字节字符截成非法序列;若先拼接 SQL 再检查长度,则已经失去防护意义。对于数组参数,还要限制元素个数,例如批量删除的 ID 列表最多 100 个,避免一次请求拖垮数据库。
规范化与编码:让同一含义只有一种形态
规范化(normalization)是把输入转换为统一的标准形式,再执行校验。常见操作包括:Unicode NFC/NFKC 归一化、大小写统一、去除首尾空白、合并连续空格、URL 解码、HTML 实体解码。编码问题之所以危险,是因为同一段内容可以有多种表示:%3Cscript%3E、<script>、\u003cscript\u003e 在未解码时看似无害,进入下游后却可能还原为危险内容。
正确顺序是:先按协议解码(URL 解码、表单解码),再做 Unicode 规范化,然后执行白名单校验,最后在输出时按上下文转义。注意不要在输入阶段做 HTML 转义后再存入数据库,否则数据会被双重编码,展示时出现 < 字样。编码处理的原则是“输入解码归一,输出按上下文编码”,两者职责分离。
最小信任边界:验证发生在哪里
最小信任边界指系统只信任经过验证的数据,且验证应尽量靠近数据进入系统的位置。浏览器端校验只能提升体验,不能作为安全依据,因为请求可以被工具直接构造。服务端必须在每个入口处验证:Controller 接收参数时、调用内部服务前、访问数据库前。
对于多层架构,推荐“边界验证 + 领域校验”双层策略:边界层负责类型、长度、格式、白名单;领域层负责业务规则,如“邮箱是否已注册”“余额是否充足”。两者不能互相替代。若只在边界做格式校验,攻击者仍可能通过内部调用绕过;若只在领域层校验,非法数据已经进入系统,增加污染面。
场景化验证策略的选择依据
不同场景的验证策略差异,来自“数据最终去哪里”和“数据形态是否可控”。登录场景的用户名进入数据库查询,必须白名单 + 长度约束 + 参数化查询;密码进入哈希函数,应限制长度、禁止空字节,不做字符白名单。注册场景的邮箱要进入唯一性检查和邮件发送,应做格式校验、长度限制、域名规范化,并在领域层检查是否已存在。搜索场景的关键词要进入全文检索和页面展示,应做长度限制、控制字符过滤,并在输出时进行 HTML 转义,而不是在输入阶段穷举过滤。
可以用一张决策表来固化选择依据:
| 场景 | 主要风险 | 首选策略 | 不推荐做法 |
|---|---|---|---|
| 登录用户名 | SQL 注入、枚举 | 白名单正则 + 长度 + 参数化 | 黑名单过滤引号 |
| 登录密码 | 暴力破解、空字节 | 长度 8~128 + 哈希 + 限流 | 字符白名单、明文存储 |
| 注册邮箱 | 格式错误、重复注册 | 格式校验 + 规范化 + 唯一性检查 | 仅前端校验 |
| 搜索关键词 | XSS、资源耗尽 | 长度限制 + 输出转义 | 输入阶段 HTML 转义后入库 |
| 分页参数 | 越界、慢查询 | 整数解析 + 范围 1~1000 | 直接拼接 SQL |
把上述原则落到代码结构上,可以形成一个可复用的验证入口:每个外部参数先经过统一的 normalize(),再进入 validateType()、validateAllowlist()、validateRange(),最后交给领域服务。这样既保证最小信任边界清晰,也让登录、注册、搜索等场景共享同一套验证骨架,只在具体规则上做差异化配置。