输入校验的四层:格式、范围、关系与权限
卷中目录
输入从边界进入系统时,还只是一串不可信的字节。把它直接送进领域逻辑,参数错误会散落在各处,有的变成模糊的服务器异常,有的变成错误的业务数据,有的甚至变成越权。校验可以分成四层:格式、范围、关系、权限。四层回答的问题不同。格式问“这是不是我们约定的形状”,范围问“单个值是否落在允许区间”,关系问“多个值放在一起是否成立”,权限问“这个主体能否对这个对象做这件事”。混在一次判断里,错误码会不稳定,调用方也不知道该改字段、调整组合,还是申请授权。

分层不是为了把同一条规则写四遍。每一层只拒绝自己能确定的问题,并把已经澄清的值向内传递。外层失败就不必继续。内层不要重新解析原始字符串,避免两套解析规则逐渐分叉。校验失败应返回稳定的字段路径和原因,而不是只丢一句“参数错误”。同一请求里可以收集多个格式和范围问题一起返回,让调用方一次改完。关系和权限有时依赖外部数据,是否合并返回要看成本。权限失败若会泄露资源是否存在,则应单独按安全策略处理,不要为了一次返回更多错误而暴露信息。
格式:先变成类型
格式层负责结构。字段是否存在、类型是否正确、字符串是否符合约定的编码、时间是否能被解析、枚举是否落在已知名字里、嵌套对象的形状是否对。这一层不需要访问数据库,也不需要知道当前用户是谁。它适合放在边界上:控制器、消息消费者的入口、批处理文件的读取处。过了这一层,内部代码面对的应是已经构造好的类型,而不是随时可能缺键的字典。
格式要具体。标识符若约定为固定长度的十六进制,就不要只判断“非空字符串”。时间若要求带时区,就不要接受一个没有偏移的本地时间再暗中当成某一时区。金额若以最小货币单位的整数传递,就不要在格式层收下浮点数字符串,把舍入推迟到不知道哪一行。枚举要拒绝未知值。未知值若被静默映射成默认值,新老客户端会在不知情的情况下执行另一种业务。多余字段也要有策略:忽略可以让协议演进更平滑,拒绝可以防止调用方误以为那些字段生效。选择哪一种可以讨论,但不能一半接口忽略、一半接口把多余字段写进数据库。
集合需要格式上的限制:数组是否允许为空、元素类型是否一致、嵌套是否有深度上限。没有深度上限的嵌套结构,会让解析本身占用过多内存。二进制上传要检查声明的媒体类型和实际内容是否匹配,并且限制体积。这些仍属于格式和资源保护,不是业务范围。
失败信息要指向路径。items[2].count 比“第几个商品数量不对”更可定位,也避免依赖自然语言。对用户界面可以再翻译成友好文案,但服务之间应保留机器可读的路径和错误码。格式错误通常不需要重试。调用方改请求之前,同样的字节只会得到同样的拒绝。
范围:单个值的允许区间
范围层看已经解析好的值是否在业务允许的区间内。数量不能为负,页大小不能超过服务能够承担的上限,标题长度不能超过存储和展示的限制,折扣比例必须处于约定区间,文件扩展名必须属于允许的集合。范围规则往往来自产品和容量,而不是来自语法。零是合法整数,却可能不是合法的购买数量。空数组语法正确,却可能不构成一次下单。
范围应尽量集中定义,避免控制器里写一次、领域对象里再写一个略有不同的上下限。存储列的长度是硬边界。应用若允许比列更长的值,错误会变成数据库异常,调用方只能看到含糊的失败。应用限制可以严于存储,以便留出后缀或编码开销,但不应松于存储。数值范围还要考虑累加之后的溢出。单价和数量各自合法,相乘却可能超出金额类型的上限。这种判断已经靠近关系层,范围层至少要保证单个值在类型安全的区间内。
分页、批量接口的上限是保护系统的范围规则。允许调用方一次传入数万个标识,格式上完全正确,服务却会在查询和序列化上被拖垮。上限要写进协议,超限返回明确错误,而不是截断后只处理前面一部分。截断会让调用方以为全部生效。时间窗口同样如此。查询区间若没有上限,一个跨越许多年的统计请求会占用过量资源。可以要求调用方缩小区间,或把大查询引导到异步导出。
默认值要谨慎。调用方没传排序字段时采用约定的默认排序,这是协议的一部分,应在文档中写明。没传金额就默认成零,则可能把错误请求变成一笔免费订单。能够区分“未传”和“传了空值”时,不要把两者都当成默认。范围错误和格式错误一样,通常可以在不访问权限系统的情况下返回,但不要在错误信息里回显过多的内部上限策略,如果这些上限本身是不希望被探测的容量细节。一般的页大小上限可以公开,内部风控阈值则不必写进给外部的句子里。
关系:值与值、值与已有事实
关系层处理组合。开始时间必须早于结束时间,收货地址必须属于该用户已存在的地址,优惠券必须适用于购物车里的商品,改期后的班次必须还有可分配座位,子项目的合计必须等于抬头上的总额。这些规则依赖多个字段,常常还要读已有数据。它们是领域规则,不是通用校验器里的正则。
关系校验要明确自己读到的是哪一时刻的数据。先检查优惠券可用,再进入事务扣减,中间优惠券可能被别的订单用掉。因此,真正要坚守的关系必须在提交的那一次原子操作里再确认一次。边界上的预检查用来尽早返回友好错误,事务内的约束用来保证正确。只有预检查没有约束,并发下会漏;只有约束没有预检查,用户会把唯一约束失败当成服务器故障。两种失败应映射成稳定的业务错误,而不是把数据库报错原文返回。
关系也可能跨服务。库存是否足够,有时要问库存服务。这里要区分强关系和提示。下单数量不得高于可售数量,若这是必须遵守的规则,就要在扣减的那一次原子判断,不能只相信片刻之前的查询。页面上显示的剩余量则可以是提示,允许略微过时。把提示当成规则,系统会过度拒绝;把规则当成提示,又会超卖。接口文档里写清哪些条件是确定的失败,哪些是尽力而为的预估。
部分关系来自配置。包邮门槛、可预约日期、允许的支付方式组合,会随运营调整。把它们写死在代码常量里,会让每次调整都变成发布。放到可版本化的配置里,校验时读取当前生效的版本,并把版本号记进订单,后续争议才能复原当时的规则。配置缺失或不可解析时,应当失败而不是按一套隐藏的旧规则继续。隐藏回退会让人以为新配置已经生效。
关系失败的信息要说明哪一组值不兼容,而不是逐个字段说它们自己合法。开始和结束时间都落在允许的年份里,问题出在顺序。返回两者的路径和“结束必须晚于开始”这类原因,调用方才知道改哪一边。不要在这一层重复格式判断。若结束时间根本无法解析,请求应当在格式层已经返回,关系层收到的应是两个确定的时间点。
权限:主体、动作与对象
权限层回答另一件事:当前主体能否执行这个动作。它不是字段格式的附属检查。一个形状完全正确、范围合理、关系成立的请求,仍可能是越权。主体来自已经验证的会话或令牌,不来自请求体里可伪造的用户编号。对象要按资源的真实归属判断。只在查询条件里加上调用方传来的用户编号,调用方可以改编号;应当用会话中的主体去约束查询,或读取资源后再比较归属。
常见的漏洞是先用资源编号取出对象,不检查归属就更新。编号可猜测或可枚举时,这就是越权访问。权限层应把“存在但无权”和“不存在”按策略区分。若资源编号本身不可猜测,返回不存在可以减少信息;若编号会顺序出现在别的页面,攻击者仍能探测。对敏感资源,无权和不存在可以返回同一响应,避免确认某条记录在系统中。对用户自己的资源,说清不存在更利于排错。策略要统一,不能这个接口泄露、那个接口隐藏。
权限不只是角色名。角色决定一大类能力,数据范围决定能碰哪些行。客服可以查看订单,不代表可以查看任意用户的订单。操作也要细分。能读取地址不等于能修改地址,能创建退款申请不等于能批准退款。把这些都收成一个“已登录”判断,格式和范围再严也挡不住合法登录用户的越权。
权限检查的位置要靠前,但又必须使用已经解析的对象身份。对明显无权的批量操作,可以在做昂贵的关系查询前先拒绝。检查结果可以短时缓存,但权限变更、成员退出、令牌吊销之后,缓存必须失效。只在界面上隐藏按钮不是校验。隐藏能改善体验,真正的拒绝必须发生在服务端。后台任务和内部接口同样要带主体。用一个超级服务账号执行所有异步任务,会绕过对象级权限。任务应记录触发者,并在执行时重新检查触发者是否仍有权,或者明确这是系统主体执行的补偿,权限模型里有对应的系统规则。
多租户是权限层的一种范围。租户标识应从主机名、令牌或服务端会话推导,不能只信请求体。每次查询和更新都要带上租户条件,唯一约束也要包含租户,避免跨租户的主键碰撞被当成同一资源。漏掉一次按主键的更新,就会跨租户写入。
四层如何衔接
一个更新收货地址的请求可以这样走过四层。格式层确认地址编号是合法标识,电话符合约定模式,邮编字段的字符集正确。范围层确认收件人长度、电话区号处于支持的地区。关系层确认该地址的地区仍在配送范围内,修改后的默认地址不会让用户失去全部默认项,这些判断在事务里再由约束保护一次。权限层确认当前用户拥有这张地址,而不是只因为编号存在就允许修改。任一层失败都有自己的错误码。成功之后,领域模型收到的是已类型化、已授权的命令,而不是原始请求。
消息和文件要走同一套层次。消息体是另一种边界,不能因为来自内部主题就信任其字段。内部调用方也会有缺陷。可以减少重复的格式说明,但不能跳过关系和权限。定时任务扫到的每一行,在产生外部效果前也要重新检查状态关系,避免使用已经过期的任务参数。
校验与清洗要分开。清洗是去掉首尾空白、统一大小写、把全角数字转成半角。清洗规则必须稳定且可见。悄悄删掉调用方认为有意义的字符,会造成“我明明输入了却被改掉”的争议。危险内容的过滤,例如脚本片段,应在输出到页面时做编码,这是输出侧的责任。输入层拒绝明显不合法的内容可以做,但不能代替输出编码。把所有尖括号都删掉,会破坏合法的比较表达式或代码片段类产品。
错误聚合也有安全边界。可以一次返回全部格式和范围错误。权限失败时,不要附带资源的内部字段来“帮助调试”。关系层若为了给出更准确的原因而查询了其他用户的数据,原因文案里不能暴露那些数据的细节。日志可以更详细,但日志本身也要避免记下完整证件号、令牌和支付信息。校验失败是攻击者探测系统的通道,响应应当稳定、可预期,并且不因记录是否存在而在时间上差异过大。对存在性敏感的接口,权限判断的耗时不要明显短于正常查询。
测试可以按层组织。格式层用缺字段、错类型、坏枚举和过深嵌套。范围层用边界值:刚好允许、刚好超过、零、负值、空集合。关系层用彼此冲突的合法值,以及并发下预检查通过但提交失败的情况。权限层用其他用户的资源编号、其他租户的标识、只读角色发起写入、令牌过期后的重放。四类测试混成一堆“参数错误”断言,重构时就无法知道哪一层被破坏。
收束
格式把字节收成类型,范围拦住单个值的越界,关系确认组合与已有事实仍然成立,权限确认主体与对象匹配。外层尽早失败,内层在事务和授权点上做最终确认。预检查用来改善错误体验,约束和对象级权限用来保证结果正确。四层分开之后,非法输入会停在相应的边界上,领域逻辑面对的才是可解释的命令,而不是一串还需要到处设防的原始字段。
见字如晤