Handlebars 的 AST 校验:枚举三种类型为什么不够

发布时间:2026/10/21 20:52:58
Handlebars 的 AST 校验:枚举三种类型为什么不够 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-33937GHSA-2w6w-674q-4c4qCWE-843 与 CWE-94受影响区间自 4.0.0 起至 4.7.8记为 CRITICAL。六个月后的 10 月 5 日4.7.10 又修了一次CVE-2026-106446GHSA-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(inputnull||(typeofinput!stringinput.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.typeProgram){// 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是否为字符串数组NumberLiteraltypeof node.value ! number || !isFinite(node.value)则报错BooleanLiteraltypeof node.value ! boolean则报错。三种类型。而StringLiteral不在列表里。遍历逻辑本身还有两处特征if(typeofnode!object){return;}以及Object.keys(node).forEach(propertyName{if(propertyNameloc){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-106444GHSA-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-106445GHSA-p8wg-vrv2-v86f严重分类为 CWE-1289 未恰当校验输入与 CWE-184 不完整清单Handlebars 有一份拒绝名单用来挡住从模板访问constructor一类危险属性。它的检查逻辑是“如果这是自有属性就直接返回”而Function.prototype.constructor恰好是Function.prototype的自有属性于是名单还没被查到Function已经被交出去了。这需要调用方开启allowProtoMethodsByDefault: truehbs这个包默认就开着它所以它必须跟着升级。三份公告指向的其实是同一个更大的问题当一个模板编译器接受的对象图来自不可信输入时需要校验的不只是“值像不像字面量”还有“这段值的消费路径要经过哪些地方”。而这三处的消费路径各不相同。一处是生成代码时的拼接一处是产物被内联进 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 的做法是同一个思路的两个极端要么让每个消费点负责要么让需要被负责的东西不存在。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询