☰
Handlebars 的 AST 校验:枚举三种类型为什么不够
2026/10/21 20:52:43 网站建设 项目流程

Handlebars 的compile()除了接受模板字符串,还接受一个已解析好的 AST 对象。这个能力本身合理,预编译流程需要它。但它同时把“不可信输入”的定义从字符串换成了一个结构化的对象图,而校验这种对象比校验字符串难得多。先把 AST 和“为什么接收它”说清楚,因为后面所有判断都建立在这一点上。模板Hello {{name}}交给 Handlebars 之后,会被解析成一棵对象树:一个Program节点,下面挂一个MustacheStatement,里面再挂一个PathExpression表示name。这棵树就是 AST,它是模板的结构化表示,编译器后续的代码生成就是沿着这棵树走的。

compile()允许两种输入:字符串,或者已经解析好的这棵树。第二种用法在预编译流程里很有意义,工具链可能先解析、再做变换、然后才交给编译器,中间不必把树再序列化回字符串重新解析一遍。但对安全来说,这个能力改变了一件根本的事:解析这一步被跳过了。正常流程是“不可信的字符串 → 解析器 → 树”,解析器对字符串做了完整的语法分析,产出的树在结构上一定合法。而第二条路径是“调用方给什么树,就是什么树”,树的结构不再有任何东西保证。所以校验必须补在解析器原本的位置上:树被接受的那一刻。这就是 4.7.9 把校验放进parseWithoutProcessing()的原因,那个函数正是“字符串要不要先解析”的分岔点。

2026 年 3 月 26 日发布的 4.7.9 为此加了一套校验,CVE-2026-33937(GHSA-2w6w-674q-4c4q,CWE-843 与 CWE-94,受影响区间自 4.0.0 起至 4.7.8)记为 CRITICAL。六个月后的 10 月 5 日,4.7.10 又修了一次,CVE-2026-106446(GHSA-8r5x-fm3f-whwj,同样记 CWE-843 与 CWE-94,受影响至 4.7.9)的标题里直接写着 “bypass of CVE-2026-33937”。把版本时间放在一起看,有一个间隔:4.7.8 发布于 2023 年 8 月 1 日,4.7.9 发布于 2026 年 3 月 26 日,两者相隔两年半。也就是说,“接受已解析 AST”这个能力存在了很久,而专门为它写的校验是两年半之后才有的。不是能力变了,而是这个能力可被滥用这件事在那时被发现。两版修复的差别不在“查得更严”这个方向上。4.7.9 把校验加在入口,4.7.10 把它挪到了分派点。位置的改变带来的是完备性来源的改变。

入口的门槛有多低

先看那个接受测试。v4.7.8 的compile()是这样开头的:

exportfunctioncompile(input,options={},env){if(input==null||(typeofinput!=='string'&&input.type!=='Program')){thrownewException('You must pass a string or Handlebars AST to Handlebars.compile. You passed '+input);}

precompile()里有一份相同的检查。这两个条件就是全部门槛:不是null,并且“要么是字符串,要么有一个等于'Program'的type字段”。这是一次字段值比对,不是类型判断。它不检查这个对象是不是真的由 Handlebars 的解析器造出来的,没有instanceof,没有原型检查,没有任何标记。一个从 JSON 请求体里JSON.parse出来的普通对象,只要顶层带上“type”: “Program”,就能通过这道门。所以这里的问题不是“校验太松”,而是这道门从一开始就没打算承担校验职责。它的作用是给出一个可读的报错(“你必须传字符串或 AST”),而不是判断输入是否安全。真正承担安全职责的是它下游的那套校验,而那套校验是 4.7.9 才加的。

4.7.9 查了什么

4.7.9 的校验写在lib/handlebars/compiler/base.js的parseWithoutProcessing()里,而它的位置很关键,这是所有解析流程的共同入口:

exportfunctionparseWithoutProcessing(input,options){// Just return if an already-compiled AST was passed in.if(input.type==='Program'){// When a pre-parsed AST is passed in, validate all node values to prevent// code injection via type-confused literals.validateInputAst(input);returninput;}

设计意图直接从注释里读得出来:当一个已经解析好的 AST 被传进来时,校验它的全部节点值,防止经由“类型混淆的字面量”注入代码。它走遍整棵树,按节点的type分派检查:

  • PathExpression:检查depth是否合法、parts是否为字符串数组;
  • NumberLiteral:typeof node.value !== 'number' || !isFinite(node.value)则报错;
  • BooleanLiteral:typeof node.value !== 'boolean'则报错。三种类型。而StringLiteral不在列表里。遍历逻辑本身还有两处特征:
if(typeofnode!=='object'){return;}

以及

Object.keys(node).forEach(propertyName=>{if(propertyName==='loc'){return;}validateAstNode(node[propertyName]);});

第一条是“不是对象就返回”。第二条是“遍历除了loc之外的每一个属性”。两条都不算错,它们是一棵通用 AST 遍历器的常规写法。问题在于这套检查的组织方式:它按type分派,因此没有type的东西不受任何检查;而它只递归对象,因此字符串和其他非对象值也不受检查。

绕过是怎么产生的

4.7.10 的公告把根因写得很直接:

The 4.7.9 validator runs inparseWithoutProcessing()and walks the whole AST. It only checks values on nodes whosetypeisPathExpression,NumberLiteralorBooleanLiteral. It passes over plain objects without atype, and over strings and other non-object values. The compiler reads these values from other places and writes them into the generated code as-is.

最后一句是整件事的关键:编译器从别的地方读这些值。要理解为什么“别的地方”是个问题,得看清校验器与编译器的工作方式不同。校验器做的是遍历:从根节点出发,看每个节点、每个属性、递归下去。编译器做的是按模板语义取数:它走到某个位置时,知道此刻需要哪些值,比如构造一个块程序的选项对象时,需要知道这个块有几个参数。两种方式读的字段集合因此不一样。遍历器读到的是“树上存在的东西”;编译器取到的是“它这一刻需要的那些东西”。前者是全集,后者是它实现里出现的那些。这两者不重合的地方,就是绕过可以藏身的地方。而“写进生成代码”这个动作,是把问题从“取值”升级成“注入”的那一步。编译器最终产出的是一段 JavaScript 源码——container.program(0, data, 两个参数)这样的调用会被拼进那段源码里。如果一个取自 AST 的值没有经过任何处理就被拼进去,那么它的内容就成了代码的一部分。这就是“类型混淆”这个名字的由来:一个本该是数字的值位置上出现了字符串,而下游把它当代码写了出来。公告给出了一张对照表:

AST 里的值被写进生成代码的形式需要什么编译选项
Program.blockParams.lengthcontainer.program(<index>, data, <length>)默认选项即可
非PathExpression参数的depthdepths[<depth>]stringParams: true
非字符串的StringLiteral.value原始 JavaScript 字面量stringParams: true
非字符串的PathExpression.original原始 JavaScript 字面量stringParams: true

四行里只有第一行在默认配置下成立,这一点必须说清楚,后三行需要调用方开启stringParams。

stringParams这个选项与“参数是不是字符串”有关:默认情况下,编译器会把参数值一律转成它自己的内部表示;开启这个选项后,字符串参数会被当作字符串参数单独处理,走的是另一条代码路径。那三行绕过都出现在那条路径上,因为它的处理更直接,直接把值当字面量写出去。而blockParams那一行不需要任何选项,因为它走的是最常规的路径:任何带参数的块都会被编译成一次container.program(...)调用,而那个调用要把参数个数写进去。换句话说,那个绕过点不是某个特殊配置下的边角,而是默认行为的一部分。而第一行不需要任何特殊配置,因为它涉及的是blockParams的长度:编译器要把它写进container.program(...)调用的参数位置。第一行为什么能穿过校验,公告也说了:

AProgramwithblockParams: { “length”: “<JS expression>” }passes validation because: 1. theblockParamsobject has notype, so none of the type-specific checks apply, and 2. the walker skips the string underlengthbecause it is not an object.

两个条件正好对上前面那两处遍历逻辑:blockParams是一个没有type的普通对象,所以按类型分派的检查一个都不适用;而length下面那个值是字符串,所以递归在它之前就停了。于是校验器完全走过这个位置,而编译器照旧读走program.blockParams.length,把它写进生成代码。公告还指出一个细节:container.program(...)是 helper 选项对象的一部分,在 helper 被查找之前就构造好了,所以这段代码在渲染时执行,即使模板块里引用的 helper 根本不存在。

同日的另外三份

10 月 8 日那一天 Handlebars 发了四份公告,除上面那份之外还有三份,各自的问题位置不同。

</script>内联(CVE-2026-106444,GHSA-xw65-4hp5-5hc7,中等,分类为 CWE-116 编码转义不当):预编译产出的 JavaScript 常常被直接内联进 HTML 的<script>元素。修复前,模板里的静态文本原样输出,而 HTML 解析器在 JavaScript 字符串字面量内部也认</script>,遇到它就关闭当前脚本元素,后面跟着的标签就变成 HTML 被解析、被执行。这处的修复在code-gen.js:新增SCRIPT_DELIMITERS = /<(?=!--|\/?script)/gi,把匹配到的<换成\u003C,并让quotedString改为委托safeJsonLiteral。4.7.8 版的那个函数只转义反斜杠、引号、换行和两个行分隔符,不含<。这处的原因不在 Handlebars:它说明“生成合法 JavaScript”和“能安全地内联进 HTML”是两件事。生成的代码在 JavaScript 层面完全正确,引号配对、转义完整;出问题的是它被放进了一个有自己解析规则的宿主里。自有属性检查绕过(CVE-2026-106445,GHSA-p8wg-vrv2-v86f,严重,分类为 CWE-1289 未恰当校验输入与 CWE-184 不完整清单):Handlebars 有一份拒绝名单,用来挡住从模板访问constructor一类危险属性。它的检查逻辑是“如果这是自有属性就直接返回”,而Function.prototype.constructor恰好是Function.prototype的自有属性,于是名单还没被查到,Function已经被交出去了。这需要调用方开启allowProtoMethodsByDefault: true(hbs这个包默认就开着它,所以它必须跟着升级)。三份公告指向的其实是同一个更大的问题:当一个模板编译器接受的对象图来自不可信输入时,需要校验的不只是“值像不像字面量”,还有“这段值的消费路径要经过哪些地方”。而这三处的消费路径各不相同。一处是生成代码时的拼接,一处是产物被内联进 HTML,一处是属性查找时的名单判定。生态层面还有一处细节。自有属性绕过那份公告专门加了一句:如果用的是hbs这类包,它们默认开启allowProtoMethodsByDefault: true,因此必须跟着升到最新版。这句话的分量在于:那处绕过需要调用方开启一个非默认选项才能触发,而对直接使用 Handlebars 的应用来说默认是关的,但下游包装包把这个前提改掉了。一个默认值的选择,决定了一个并非默认可达的问题对多少应用变成默认可达。把它们放在一起看,能得到一个比“接不接受 AST”更宽的结论:一个值从不可信输入走到“被当作代码或标记解释”的位置,中间要经过若干次转换;而每一次转换都是一个可以放置校验的点。如果所有转换都不校验,问题就是 4.7.9 之前的状态;如果只有某一个转换校验,那么从别的路径过去的输入就漏掉了。

</script>那一处尤其能说明这一点。它的问题完全不在 AST 上,模板字符串同样会触发,因为它是预编译产物被内联进 HTML 时的问题。同一个产品在两个层面各有一条注入路径:一个在编译期、一个在产物被使用时。

4.7.10 换了什么

现在看第二版修复的位置变化。最直接的证据是一个文件从有到无:base.js在 4.7.9 是 101 行(含那套校验),到 4.7.10 回到 34 行,与 4.7.8 字节等价——parseWithoutProcessing重新变成“原样返回 AST”。校验被从入口移走了。它去了分派点。4.7.10 的compiler.js在accept()里加了这一道:

accept:function(node){// Dispatching on an unknown type could call an arbitrary Compiler method.if(indexOf(NODE_TYPES,node.type)===-1||!this[node.type]){thrownewException('Unknown type: '+node.type,node);}

注释把理由写明了:按未知类型分派可能调到编译器上的任意方法。这一句点出的是“按类型分派”这种写法的固有风险。accept()的实现方式是this[node.type](node),用 AST 里的type字段当方法名,去编译器对象上取同名方法。正常情况下node.type只能是解析器产出的那十几种,取到的必然是预期的方法。但当type来自不可信输入时,这个方法名就是攻击者可以填的。this['constructor']、this['toString']、乃至通过原型链够到的任何东西都能被取出来并调用,取方法这件事本身变成了一次按攻击者指定的名字进行的查找。这类写法在 JavaScript 里很常见(按类型分派、按事件名查表),而它安全的前提正是“这个名字来自可信来源”。新写法先比对白名单,再确认那个名字确实对应一个方法,把“名字能不能用”这件事从隐式变成显式。白名单来自新增的node-types.js,里面是一个 18 项的数组,从Program、BlockStatement一路到Literal家族与Hash、HashPair。这里要避免把它读成一次泛泛的“改白名单”。这份白名单的特殊之处在于它是封闭的:它的内容由编译器自己决定,不由外部的危险清单决定。accept()要做的事是this[node.type](node),用节点类型名去编译器对象上取方法。而编译器上有哪些方法,是这份代码自己写出来的;因此“合法的类型名有哪些”这个问题有一个可直接推导的答案:凡是编译器上有对应方法的类型名,就是合法的。这与“列出危险的属性名”是根本不同的两件事。前者的清单可以从代码本身读出来,因此不会漏;后者的清单只能从经验的累积里长出来,因此总在补。同一个文件里恰好有一个正反对照:那份挡住constructor的拒绝名单,就在同一天被证明漏了Function.prototype.constructor这一项。而各处理器开始各自校验自己要用的值:

StringLiteral:function(string){validateLiteral(string);this.opcode('pushString',string.value);},NumberLiteral:function(number){validateLiteral(number);this.opcode('pushLiteral',number.value);},

另外Program的处理里补了validateBlockParams(program),正是绕过所用的那个字段;pushParam里补了validateLiteral与validateDepth,并对stringParams模式做了一次字符串化(注释说明:那个模式会把非字符串值作为原始 JavaScript 字面量写出,字面量本身已校验,但original没有,所以凡是不是数字或布尔的都转成字符串)。这批辅助函数的形状本身就是那个位置改变的产物。它们各自只服务于一类值:validateLiteral判字面量、validateDepth判深度是不是非负整数、validateBlockParams判它是不是一个字符串数组、validateStringArray与validateParts判数组元素是不是字符串。没有一个函数试图判断“这棵 AST 是否合法”,那是 4.7.9 那个validateInputAst想做的事,也是它做不成的事。还有一处细节能看出这套写法的取向:stringParams那条路径上,缺校验的那个值(original)没有被补一个校验函数,而是被转成了字符串。理由是它的消费方式只有一种,被当字面量写出去:所以只要保证它一定是字符串,就没有注入空间。当一个值的用法只有一种时,把它的类型定死比校验它的内容更省事也更可靠。这个validateLiteral检查的是这三个条件:节点必须是对象、必须有type、并且value的类型必须与type对应(StringLiteral要字符串、NumberLiteral要有限数字、BooleanLiteral要布尔)。对照 4.7.9 的版本,检查项本身没有变多,变的是它现在会在每一个用到字面量的地方被调用,而不是只在那一次树的遍历里被调用。

这就是位置改变带来的一切。校验不再试图在入口预判“编译器会读哪些字段”,而是分散到每个消费点,由消费点校验自己马上要用的那一份。完备性的来源从预判变成了归属:每个处理器只需要对自己拿到的节点负责,不需要知道别的处理器会读什么。顺带把上一版的一处设计补上:StringLiteral现在也在校验范围内了。4.7.9 遗漏它并不是疏忽,那一版是按“会产生字面量的节点”来列名单的,而StringLiteral看起来像字符串、不像字面量。按特征列名单总会有这种判断失误,而按消费点校验不需要做这个判断。

还有一处可以顺手核对的痕迹:4.7.10 把校验从 base.js 移走、使那个文件回到 4.7.8 的形态,这件事在版本对比里非常显眼——一个为修漏洞而新增 67 行的文件,在下一版里又退回去了。文件行数的这种往返,是"校验位置被改变"最直接的信号,比读校验逻辑本身更快。

一处公告与源码的差异

公告里的一段示意代码说明了“值被原样写出”这件事。这里必须补一句:那段代码不是上游源码。公告的写法是compiledCode += numberLiteralNode.value;,但在 v4.7.7 到 v4.7.10 的四个版本里,javascript-compiler.js中NumberLiteral这个标识符是零命中,那个文件按 opcode 名分派,不按 AST 类型分派。真实的链路是这样:

NumberLiteral 节点 → compiler.js 的处理器调 opcode('pushLiteral', number.value) → javascript-compiler.js 的 pushLiteral(value) 调 pushStackLiteral(value) → pushStackLiteral 把值包成一个 Literal 对象 → popStack 对 Literal 直接 return item.value

而对照组就在同一段代码里:pushString会先调this.quotedString(string),pushLiteral则直接传给pushStackLiteral,中间没有任何处理。“字符串会过一遍引号处理、字面量不会”这个差别真实存在,公告要表达的意思是对的,但它给的代码是简写。之所以要专门指出这一点:照着公告的示意代码去搜上游,会一无所获。而“值在哪一步被原样写出”这件事需要看到真实的调用链才能确认——pushLiteral与pushString的对照才是那一处证据。

校验位置决定完备性从哪里来

把两版修复并排,可以看到一个可以直接推广的形态。入口式校验把完备性寄托在“我预判了全部消费点”。它需要在写校验的那一刻就知道:整棵树上哪些字段最终会被写进生成代码、哪些会在属性查找时被用到、哪些会被内联进 HTML。4.7.9 的缺陷不是没想到某个字段,而是这个清单本身无法在一次预判里列全,因为消费点分散在编译器、代码生成器、运行时查找三处代码里,而且会随着功能增加而变化。分派点校验把完备性改成了每个位置的局部责任:拿到节点的处理器校验自己要用的值,做属性查找的代码校验自己的名单,生成 HTML 内联产物的代码转义自己该转义的字符。没有任何一处需要知道全局。这个形态有一条实用推论:看到一处“在入口处集中校验输入”的代码时,要问的不是“它查得够不够严”,而是“它下游有多少个消费点,以及它是否知道全部消费点”。如果答案是“消费点分散在多个模块、且入口校验的字段清单是被枚举出来的”,那么这类绕过就不是偶然,而是这种结构迟早会产生的。

Handlebars 这次的六个月间隔,可以理解为这个结构在陆续交出它遗漏的消费点:4.7.9 之后先补齐了三处(stringParams相关的那三个值),而blockParams这一处要等到十月的四份公告一起处理。这份工作里最有用的一个信号是:如果一份修复说明能写成“现在每个消费点都各自校验了”,那它已经换了形态;如果只能写成“现在多检查了 A、B、C”,那么它还停在入口式校验里。

入口式校验的漏洞不会一次暴露完。

一个可以直接用的问题

把这篇的判据压缩成一句话,就是:校验在入口、而消费点分散时,要不要保这个能力。这个问题有一个不太直观的答案:当一个能力的存在理由只是方便,而它的安全代价是“必须预判全部消费点”时,去掉能力往往比完善校验更可靠。 Handlebars 自己的 workaround 就是这条:断言输入是字符串,或者改用没有compile的运行时构建。它一招解决全部四份公告里的前两份,因为它让“对象图被编译”这个前提不成立。而 4.7.10 走的是另一条路——保留能力,让每个消费点负责。这条路也是对的,而且对既有用户更友好(预编译流程不必改)。两条路都成立,但它们对维护者的负担不同:去掉能力之后,这一类问题不再需要跟踪;保留能力之后,每新增一个消费点都要记得加校验。判断该走哪条路,可以问三个问题:这个能力被多少调用方真实使用?去掉它的迁移成本有多大?保留它之后,未来新增消费点的频率有多高?对示例里的compile()来说,第三个问题的答案偏悲观——模板编译器的消费点会随着语言特性增加而增加,而每加一处就是一次“记得校验”的考验。受影响条件决定了这个问题的实际面。两份公告都写明:受影响的应用是“不可信输入能以对象形式到达compile()或precompile()”的那些,典型途径是把 JSON 请求体的字段直接传进去;只传模板字符串的应用不受影响。公告给的 workaround 也是同一句:在校验之前断言输入是字符串,或者在服务端改用handlebars/runtime,那个构建里没有compile,这个入口根本不存在。四份公告的分类分布也不一致。两份 AST 注入是 CWE-843 与 CWE-94 的组合,</script>那份是 CWE-116(编码转义),自有属性那份是 CWE-1289 与 CWE-184 的组合。四份里只有两份共享分类。这说明“同一批修复、同一天发布”不等于“同一类问题”——它们是同一轮审计在同一份代码里找到的四条不同路径,而组织它们的是发布时间,不是机制。这一点对读公告有直接影响。按发布批次去理解一组公告,会把不同的机制混在一起;按分类与受影响条件去读,才能看出哪些需要同一类处置。比如这一批里,两份 AST 注入与自有属性那份的受影响条件都依赖调用方怎么用(传对象、开原型访问),而</script>那份完全取决于产物怎么被内联:前三份要检查调用代码,最后一份要检查构建与部署方式。如果没有把compile()的输入限定为字符串的条件,那么需要问的是一个更一般的问题,它有四个可以直接核对的部分:一是入口接受什么:这个 API 接受字符串,还是也接受结构化对象?如果接受对象,那道检查是字段值比对还是类型断言?

二是消费点有几处:那些字段最终被用在哪些地方,拼进生成代码、当作方法名查找、参与条件判断、被写进产物?列出这些位置比列出字段更可靠,因为字段会新增,而消费位置相对稳定。三是校验放在哪个位置:一处集中的入口校验,还是每个消费点各自负责?前者需要一份“全部消费点”的清单,后者不需要。四是有没有绕过这条路的替代方案:能不能把能力去掉(只接受字符串)、或者在产物层面转义(像SCRIPT_DELIMITERS那样)。去掉能力往往比校验能力更彻底,这一点在公告给的 workaround 里就写着了。而这个 workaround 恰好是同一个论点的另一种表述。它不是在加强校验,而是取消了“接受对象”这个能力本身,当输入只能是字符串时,也就不存在“对象图的哪些字段会被消费”这个问题了。这与 4.7.10 的做法是同一个思路的两个极端:要么让每个消费点负责,要么让需要被负责的东西不存在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询