上一节讲访问控制时,我们解决的是“这个身份能不能做这件事”。现在把同一个请求再往里追一步:就算用户有权提交资料,服务端仍要判断年龄是不是数字、日期有没有越界、排序字段是不是系统支持的选项,以及一段文字会不会被下游组件误当成指令。
这就是输入验证真正要管的事。它不是在所有输入框前摆一张“危险字符名单”,而是为每份数据写清契约:它从哪里来,应该是什么类型,允许落在哪个范围,和其他字段有什么关系,最终会进入哪个上下文。契约越含糊,字符串拼接、隐式类型转换、重复解码和错误兜底就越容易把普通数据推到意料之外的代码路径上。
这一章会把输入验证、数据与指令分离、错误处理和最小权限放进同一条数据流里。示例只使用固定的合成数据与无害标记,不提供改变查询语义、启动额外程序或读取真实信息的载荷。任何验证都只应发生在本地靶场、教学环境,或边界和停止条件已经写明的授权系统中。

能看到输入框、能修改请求、拥有普通账号,都不等于获得安全测试授权。开始前要确认目标、接口、账号、时间窗、速率、允许的数据和禁止动作;范围没有写清时,先停下来确认。
很多输入验证代码一开始就走偏了:开发者先收集几个熟悉的特殊符号,再用替换或拒绝规则把它们挡掉。这个思路听起来很直接,实际却在回答错误的问题。它试图猜“坏输入长什么样”,却没有说明“好输入究竟是什么”。
举个很普通的例子。订单接口里的 quantity 应该是 1 到 20 的整数。最清楚的规则不是“不能包含空格、小数点或某些单词”,而是先把请求值按协议解析成整数,拒绝解析失败、超出范围和不符合业务约束的值。只要正面定义已经完整,其他情况自然都不被接受。
同一个道理也适用于状态、日期、标识符和排序选项:
这里有三个经常混在一起的问题,最好一开始就拆开:
三者不能互相代替。一个完全合法的订单编号,仍可能指向别人的订单;一段符合昵称规则的文本,仍要在进入数据库时使用参数绑定,在进入 HTML 时按输出位置处理。把“校验通过”理解成“到处都安全”,是输入验证最常见也最隐蔽的误区之一。
你可以把校验规则看成接口的一部分,而不是安全团队临时加上的过滤器。字段类型、长度、范围、必填关系、未知字段策略和错误语义都应该能被测试,也应该随着业务变更一起评审。
只盯着页面上的文本框,很容易漏掉真正危险的输入。HTTP 查询参数、路径段、请求头、Cookie、JSON 请求体和上传元数据当然属于外部输入;消息队列事件、Webhook、批量导入文件、第三方接口返回值、环境配置,以及数据库里稍后又被读取的数据,同样可能跨过信任边界。
尤其要当心“已经存进数据库,所以可信了”这种错觉。数据第一次写入时可能只用于展示,几天后却被报表任务拼进查询、被通知服务拼进模板,或被转换服务当作程序参数。风险发生在最终使用点,而不是发生在数据第一次抵达系统的时刻。
对每个重要字段,至少记下下面几件事:
这张图的价值不在于画得漂亮,而在于让团队发现“校验发生在 A 版本的数据上,执行却发生在 B 版本的数据上”这类错位。例如,网关检查了原始路径,框架随后又做了一次解码;一个服务验证了字符串,另一个服务把它自动转换成对象;前端删除了空白字段,后端却用默认值把它补成了另一个业务动作。
一个容易审计的输入管线通常按固定次序工作:
在协议边界限制请求体大小、字段数量、嵌套深度和允许的内容类型。体积明显不合理的数据应在进入昂贵解析器前被拒绝。
只按协议约定解码一次,并拒绝非法编码、歧义编码和意外的重复字段。不要让不同组件各自猜测怎样解释同一份字节。
根据字段用途选择明确的规范化策略,再对规范化后的值做类型、长度、范围和允许列表校验。需要保留原始文本时,把原值与规范化值的用途分开,而不是混用。
在单字段规则通过后检查跨字段关系、对象状态和业务限制,例如日期顺序、库存上限、状态迁移和租户归属。
“只解码一次”不是要求所有系统永远只有一个字节转换动作,而是要求每个协议层的职责清楚、顺序一致,并且安全判断针对最终会被业务使用的表示。校验后再发生未经约束的解码或拼接,相当于把已经检查过的东西换成了另一件东西。
规范化和校验经常被写在同一个函数里,于是团队很容易把它们当成一回事。其实规范化解决的是“等价输入用什么统一表示”,校验解决的是“这个表示是否符合规则”。先后顺序错了,会产生两个组件眼中不是同一份数据的问题。
Unicode 文本就是典型例子。肉眼相同的字符可能由不同的码点序列构成。产品如果用用户名做唯一比较,可以选定一种规范化形式,让注册、登录、查询和唯一索引使用一致的表示。但兼容性规范化可能折叠本来有业务意义的差异,不能见到文本就全局套用。显示名称、法律姓名、密码和程序标识符的规则并不相同,应该由各自的数据契约决定。
还有一个常见坑:把“修改成合法值”当成默认策略。用户提交了未知状态,服务端静默改成某个默认状态,看起来更友好,却可能触发完全不同的业务流程。除非产品明确把某种变换写进契约,否则拒绝通常比猜测更安全,也更容易排查。
规范化会改变数据。凡是涉及签名校验、密码、审计原文、法律记录或需要原样回显的内容,都要先确定哪份表示参与比较、哪份表示保存、哪份表示展示。不要让一个全局“清洗函数”替产品做决定。
允许列表的核心不是“正则写得够复杂”,而是把正常输入的集合定义出来。结构越明确,规则越应该直接:布尔值就只接受协议规定的布尔类型,小枚举就精确列出选项,数字先严格转换再检查范围,标识符用完整匹配而不是查找某个片段。
假设接口需要分页参数。与其接收任意字符串后删除非数字字符,不如要求 JSON 中的值本身就是整数,并限制 pageSize 的上下界。这样既避免歧义,也不会把 "20items" 悄悄变成另一个请求。
同样,遇到对象输入时要决定怎样处理未知字段。直接忽略未知字段有时便于兼容,有时却会掩盖拼写错误或让网关与后端理解不一致。管理、支付、权限变更等敏感接口通常更适合拒绝未知字段,并对数组长度、对象深度和每个元素分别设限。
评论、地址、个人简介这类字段无法列出每个合法句子,但仍然有契约:允许哪些 Unicode 字符类别,是否允许换行,最短与最长长度怎么算,是否拒绝无效编码和控制字符,富文本由哪个专用净化器处理。
长度也要说清单位。字节数、码点数和用户看到的字符数不是同一个概念。协议限流可能关心字节,数据库列可能关心字符,界面可能关心用户感知的字形。规则没有明确单位,就会在不同层产生边界差异。
黑名单适合做异常观测或补充告警,不适合承担主要边界。原因很简单:编码形式会变化,上下文的特殊语义不同,新的解释器版本还可能增加语法。更麻烦的是,某些被列为“危险”的字符对真实姓名、自然语言或搜索内容完全正常,过滤器会先伤到合法用户。
正则表达式也不是越长越安全。它应完整匹配预期格式,设置明确长度,并避免会在异常输入上产生灾难性回溯的结构。规则复杂到没人能解释时,拆成类型转换、枚举判断和几段清晰检查,通常更容易验证。
浏览器中的即时提示很有用,它能让用户少提交一次请求。但客户端代码由请求方控制,可以被跳过或修改,因此服务端必须独立执行同一份安全规则。更稳妥的做法是从同一份接口模式生成客户端提示和服务端校验器,同时把服务端结果当作最终结论。
到了数据库、操作系统工具或模板引擎附近,输入验证常被寄予了过高期待。团队会说:“这个字段只允许正常字符,所以拼接应该没事。”问题在于,业务规则会变,Unicode 与编码会变,不同解释器对同一字符的理解也会变。只靠过滤器维持语法边界,等于要求每条校验规则永远准确模拟下游解释器。
更稳定的做法是让结构由可信代码定义,让外部值通过结构化接口进入数据位置。输入验证仍然保留,因为它能阻止不合业务规则的数据;但它不再负责猜测解释器语法。
这里可以记住一句很实用的话:允许列表定义“这份数据合不合业务”,安全接口定义“这份数据能不能改写指令”。 两道边界解决的是不同问题,缺一不可。
SQL 注入最常见的根因并不神秘:应用先把外部值拼进一段查询文本,再把整段文本交给数据库解析。此时数据库无法知道哪部分原本是开发者写的结构,哪部分原本只是用户提供的数据。
下面这段库无关伪代码保留了边界:
statement = database.prepare(
"SELECT item_id, title FROM items WHERE owner_id = ? AND title = ?"
)
statement.bind(1, session.owner_id)
statement.bind(2, validated_title)
rows = statement.execute()查询模板先固定,值再从独立通道绑定。数据库因此可以把 validated_title 当成一个值处理,而不需要应用自己猜怎样加引号或转义。函数名叫不叫 prepare 并不是判断标准:如果在调用它之前,代码已经把外部值拼进查询字符串,问题仍然存在。

表名、列名和排序方向通常不是普通的绑定值。假设界面提供“最新发布”“价格从低到高”两个选项,安全实现应在服务端映射:
sort_map = {
"recent": fixed_sort("created_at", "descending"),
"price_low": fixed_sort("price", "ascending")
}
sort_rule = sort_map.get(request.sort)
if sort_rule is missing:
reject("unsupported_sort")客户端只控制业务选项,实际列名与方向仍由代码决定。表名、关联路径、聚合函数和批量更新字段也遵循同样原则。无法通过参数绑定的位置,优先改成固定映射;如果选项多到无法枚举,就应该重新考虑接口是否把过多数据库结构暴露给了客户端。
ORM 的类型化查询接口通常能很好地分离结构和值,但多数 ORM 也提供原生查询、字符串条件和动态字段接口。审查必须追到最终执行方法,不能因为项目“用了 ORM”就跳过。
存储过程同样取决于内部实现。固定语句配合参数可以保持边界;在过程内部再次拼接动态查询,则只是把风险移动到了数据库里。安全评审要看值最后怎样抵达解析器,而不是看外层 API 的名字。
参数化解决的是查询结构,不会阻止一次合法但昂贵的搜索,也不会阻止越权读取。因此还要保留字段长度、分页上限和业务校验,先完成对象授权,再让应用数据库账号只拥有当前服务需要的表、视图和操作权限。读服务不该因为部署方便就拥有写权限,普通应用账号也不该拥有管理权限。
修复 SQL 注入时,最可靠的验收证据不是“原来的异常输入被拦了”,而是代码与数据库追踪都显示查询结构固定、外部值独立绑定,并且动态列名已改成服务端映射。
图片处理、文档转换和压缩功能有时确实需要调用外部程序。最危险的设计是把程序名、选项和外部输入拼成一整行,再交给 shell 解释。这里即使过滤了常见命令分隔符,也可能留下参数注入:输入没有启动第二个程序,却改变了原程序的选项或输入来源。
第一选择是不用命令解释器。创建目录用文件系统 API,下载资源用受约束的网络客户端,处理图片用对应库。通用 shell 能做的事情太多,而业务功能通常只需要其中很小一部分。

必须启动外部程序时,至少把边界写成下面这样:
format = allowed_formats.get(request.format)
if format is missing:
reject("unsupported_format")
process.run(
executable = fixed_converter_path,
arguments = ["--input", server_temp_file, "--format", format],
use_shell = false,
working_directory = isolated_directory,
environment = minimal_environment,
timeout = short_deadline
)这段设计里,程序路径由服务端固定,参数按数组传递,用户选择映射到有限格式,临时文件名由服务端生成。目标工具支持“选项结束”标记时,还可以在操作数前明确结束选项解析,但这只是补充措施,不能替代允许列表和参数数组。
进程本身也要收窄能力:使用独立低权限账号,只开放必要目录,限制 CPU、内存、执行时间、输出大小和网络访问。这样即使某一层判断出错,影响也不会自然扩展到整个主机和主应用凭据。
最理想的教学靶场会把真实执行器换成记录桩。测试者提交一个只含字母和数字的唯一标记,例如 LABIN7F2A,然后检查桩收到的是一个完整参数、固定程序和预期参数数量。这个证据已经能说明接口是否保持了边界,不需要启动额外程序,更不需要读取文件或建立外部连接。
如果生产环境不能替换执行器,优先做代码审查、调用追踪和配置核对。发现外部值进入 shell 字符串,已经足以推动修复;继续演示破坏能力只会增加业务风险,不会让根因更清楚。
模板功能很容易制造一种错觉:既然最终输出是文本,拼接一点用户内容应该没有关系。问题在于,模板引擎本身也是解释器。应用如果先把用户内容并入模板源码,再编译或渲染,普通数据就可能改变模板结构。

安全模式很直白:模板文件或模板标识由可信代码控制,外部内容放进变量字典。
template = templates.load("order-confirmation")
html = template.render({
"customer_name": validated_display_name,
"order_number": validated_order_number
})自动转义通常负责保护某种输出上下文,但它解决不了“用户输入被当成模板源码”这个问题。反过来,固定模板与变量绑定也不意味着浏览器端天然安全:变量最后落入 HTML 文本、属性、URL、样式或脚本位置时,仍需要匹配那个位置的输出处理。下一章讨论 XSS 时会继续拆解这些浏览器上下文。
有些业务确实需要让运营人员编辑通知模板。此时不要直接把完整编程语言交出去。可以选择能力受限的模板语法,只开放必要变量和有限格式化操作;把编辑、预览和发布设为不同权限;让渲染服务与主应用隔离,并撤掉不必要的文件、网络、密钥和对象访问能力。
沙箱是纵深防御,不是把不可信代码变成可信代码的魔法开关。模板引擎升级、扩展函数和应用暴露对象变化都可能改变沙箱边界,所以功能允许列表与进程隔离仍然必要。
授权靶场中,可以让测试渲染器只输出解析后的节点类型或编译事件。提交无害标记后,观察它是否保持在变量值节点;如果节点结构随输入改变,就已经得到了足够的修复证据。生产环境里不应尝试读取对象属性、文件或环境变量来“证明影响”。
校验失败不是罕见异常,而是接口的正常分支。服务端应该稳定地拒绝请求,并给客户端一个能改正输入、又不泄露内部实现的结果。例如返回字段名、公开的规则编号和简洁描述,不返回数据库查询片段、绝对路径、模板栈或内部类型名。
什么时候用“格式不正确”,什么时候用“没有权限”,还要考虑对象存在性。若未授权用户可以通过两种错误精确判断某个订单是否存在,输入验证就和上一章的访问控制连在了一起。敏感对象的处理顺序与错误语义应由接口威胁模型决定,并在同类接口中保持一致。
下面这类兜底非常危险:严格解析失败后,再尝试宽松解析;JSON 模式不匹配后,把原始字符串直接传给旧接口;参数绑定报错后,退回字符串拼接。它们让正常请求看似“更兼容”,却让最不可信的数据走上最脆弱的分支。
失败应该关闭当前路径。确实要兼容旧格式时,把版本和迁移策略写明,为旧入口设置单独的规则、监控和下线时间,而不是在异常处理里猜用户本来想表达什么。
一条有用的输入拒绝日志可以包括:时间、环境、请求关联标识、已脱敏的主体与租户、接口标识、字段名、校验规则编号、拒绝类别、输入长度或类型,以及处理该请求的版本。它通常不需要完整原始值。
密码、令牌、会话标识、个人资料、支付数据和完整查询参数不应因为“方便排查”进入日志。对确实需要观察的自由文本,可以记录长度、哈希或经过批准的短摘要。日志访问本身也要授权、留痕和设置保留期限。
异常频率适合做聚合监控:某个接口的类型错误突然上升、同一主体连续触发未知字段、某个解析器的失败比例变化,都能帮助发现客户端故障或主动探测。但单次校验失败并不自动等于攻击,告警规则要结合身份、速率、业务路径和部署变更判断。
输入验证测试很容易滑向“再试一个变化看看”。专业做法是先写假设,再用最低影响的观察回答它。我们不需要让数据库返回额外记录,也不需要让系统启动另一个程序,才能证明数据与指令边界存在缺陷。

把授权矩阵放在测试记录开头。写清目标、环境、账号、接口、时间窗、速率、允许的合成数据、禁止动作、停止条件和联系人。
用正常业务值建立基线。记录请求关联标识、状态码、公开业务字段、响应长度区间和服务端对应日志;基线不稳定时先排除环境问题。
一次只改变一个属性,例如类型、边界长度、是否缺失、是否出现未知字段,或把普通文本替换成 LABIN7F2A 这样的无害唯一标记。不要同时换身份、方法和多个字段。
从应用日志、数据库审计、模板解析记录或进程测试桩核对数据落点。前端出现报错只能说明现象,不能单独证明输入进入了哪个上下文。
适合最小验证的变化包括合法边界值、超出一位的数值、空值与缺失字段的区别、数组多一个元素、对象多一个未知键、两种规范等价的测试文本,以及唯一标记的端到端追踪。它们足以检查类型、边界、模式和转换顺序,又不会携带解释器指令。
出现下面任一情况,应马上停止并按约定通报:
停止不是“验证失败”。恰恰相反,它说明测试者知道证据什么时候已经足够,也知道业务风险从哪里开始超过授权价值。
看到输入问题后,最容易做的是在控制器里再加一个过滤条件。它可能让原现象消失,却不一定修掉根因。完整修复通常要沿数据流同时处理几层。

只把某个历史输入加入黑名单,测试会跟着实现细节走。更好的回归测试验证不变量:查询结构不随绑定值变化;系统工具的程序路径与参数数量保持固定;模板节点结构不随变量内容变化;规范化是幂等的;非法类型不会进入业务层;未知字段策略在网关与应用中一致。
自动化测试还要包含正常世界。真实姓名中的标点、多语言文本、闰日、时区边界、最大合法分页值和状态机中的允许迁移,都应该通过。安全规则如果持续误伤合法输入,团队迟早会绕过它。
orderId、displayName、sortOption 是不同业务概念,即使它们在某个版本里都是字符串,也不应该统统经过同一个 sanitizeText。更好的复用单位是明确的数据类型和契约:订单标识符校验器、展示名称校验器、排序选项映射器。名字说得越清楚,调用者越不容易把一个上下文的规则误用到另一个上下文。
输入验证的成熟度,不看项目里有多少正则,而看团队能不能对每个关键字段回答:最终表示是什么,正常集合是什么,谁执行校验,失败怎样结束,数据怎样进入下游,以及下游权限有多大。
商品搜索允许自由文本,服务端删除若干特殊字符后拼接 SQL。团队认为搜索词已经被清洗,所以不必改数据库代码。你会怎样处理?
转换服务始终启动同一个工具,并且没有经过 shell,但输出格式仍由客户端原样传入参数数组。这样是否已经足够?
注册服务把用户名转成一种统一表示后检查唯一性,登录服务却直接比较原始文本。两个服务各自的代码都能正常运行,风险在哪里?
通知服务把运营人员输入的整段文本并入模板源码,渲染器开启了 HTML 自动转义。这个防线解决了什么,又没有解决什么?
到这里,我们可以把输入处理压缩成一条清楚的链:先依据协议得到唯一表示,再按业务契约判断数据是否合法,然后用结构化安全接口把值送进下游,最后用权限、隔离、错误和日志限制失误的影响。
这条链也解释了为什么“做过输入验证”不能成为万能安全结论。验证只知道字段本来应该是什么,不知道它稍后会被放进多少种解释器上下文。下一节讨论跨站脚本攻击时,数据会从服务器继续走进浏览器;同一段合法文本落入 HTML 文本、属性、URL 或脚本位置,需要的处理并不相同。
上一章的授权问题问“谁能操作对象”,这一章问“输入以什么身份进入系统”,下一章则会问“浏览器最终把输出当成数据还是代码”。把这三个问题连在一起,你会比背一份漏洞载荷清单更接近真正可复用的安全分析能力。
把通过校验的值交给参数化查询、参数数组、固定模板变量等结构化接口。校验负责业务正确性,安全接口负责守住数据与指令的边界。
用稳定的客户端错误结束失败请求,在内部日志记录规则编号和关联标识。不要在捕获异常后悄悄换一个更宽松的解析路径继续处理。
证据能够说明根因后立即停止。清理合成数据,保存脱敏证据,并把更高影响的验证交给负责人重新审批。