
正则表达式很适合做格式校验和文本提取,却也可能把一次普通请求放大成数秒甚至更久的 CPU 计算。问题通常不在“用了正则”,而在回溯型引擎遇到含糊模式和恶意输入时,必须反复尝试大量失败路径。这类正则拒绝服务(ReDoS)值得像数据库查询一样被审查和限额。
回溯为什么会失控
多数常见语言的正则引擎会在匹配失败时回退,尝试量词的不同分配方式。单层、边界清晰的模式通常很快;危险组合则是嵌套重复、相邻且能匹配同一段内容的重复,以及重复分支内部存在可选项。
例如下面的模式看似只想匹配一串 a,但最后一个非 a 字符会迫使引擎证明“所有切分方法都不成立”:
1 | const unsafe = /^(a+)+$/; |
外层 + 可以把连续的 a 分成很多组,内层 + 又能改变每组长度;失败发生在结尾时,候选切分的数量会接近指数增长。这个示例只应用于本地理解原理,不要把它放进在线接口或测试的常规输入集。
![]()
先找高风险的模式与入口
审查时不必试图“凭肉眼证明所有正则安全”,先把风险集中到输入不可信、长度可能很大、匹配失败又很重要的地方。例如搜索接口、注册表单、导入任务、日志解析器和网关规则。仓库中的每条正则都应标记输入来源和最大长度。
| 信号 | 容易出问题的写法 | 优先改法 |
|---|---|---|
| 嵌套量词 | (x+)+、(.*)* |
去掉一层重复,明确边界 |
| 重叠分支 | `(a | aa)+` |
| 宽泛通配 | .* 后再尝试多个分支 |
用排除字符类或分段解析 |
| 无长度限制 | 对整个请求体直接 test |
读取后先拒绝超限输入 |
不要只盯着“用户自己填的字段”。上游 API、消息队列、上传文件名和历史数据也都可能携带异常长的文本。特别是把正则用于过滤规则、配置模板或管理员可编辑的表达式时,模式本身也成为不可信输入,需要单独审核。
把格式校验写成无歧义的约束
很多校验表达式可以写得更短、更可预测。以用户名为例,业务规则是 ASCII 字母开头,后续只允许字母、数字和下划线,总长 3 到 32。与其使用多个可选重复组,不如把长度和允许字符直接写出来:
1 | function isValidUsername(value) { |
这里的长度检查既是业务约束,也是资源预算;字符类和定量范围让每个字符只有清晰的归属。对于 CSV、URL 或嵌套语法,不要用一条“万能正则”完成解析。先按行或分隔符限制规模,再交给相应解析器处理转义和层级,通常更容易测试和维护。
防线要同时放在入口和执行处
输入上限是成本最低、覆盖面最大的保护:网关限制请求体,应用限制字段长度和批处理条数,任务系统限制单项处理时间。它不能修复糟糕模式,但能把最坏情况关在小范围内。对于确实复杂的匹配,可采用具有线性时间保证的正则引擎,或将任务交给可终止的独立进程。

在 Node.js 这类单线程运行时尤其要小心:Promise.race、HTTP 超时或 AbortController 无法抢占同一线程里已经开始的同步 RegExp.test()。它们只能让调用方停止等待,事件循环仍可能被卡住。需要硬性时间预算时,应把匹配放到 worker 或子进程,超时后终止该执行单元,并结合并发配额防止大量请求同时占满资源。
| 层级 | 控制措施 | 解决的问题 |
|---|---|---|
| 接入层 | 请求大小、速率限制 | 阻止明显的大输入洪泛 |
| 应用层 | 字段长度、格式白名单 | 缩小每次匹配的成本 |
| 执行层 | 隔离 worker、可终止超时 | 避免单次计算拖住服务 |
| 交付层 | 基准测试、代码审查 | 阻止危险模式再次进入主干 |
用失败样本守住回归
为关键模式准备“几乎匹配但在末尾失败”的样本,并记录在合理长度下的耗时上限。测试不必断言精确毫秒数,那会受机器负载影响;更有价值的是在 CI 中对受控输入执行基准,发现明显数量级退化就失败。代码评审可把“是否存在嵌套或重叠重复”“输入最大多长”“是否可被外部控制”作为固定问题。
最后,正则并不是天然危险的工具。把它限制在边界明确、长度受控的局部匹配中,复杂结构交给解析器,再为高成本任务安排可终止的隔离环境,就能同时保留它的表达力与服务的可用性。