广告位 · 页头(后台-模块管理-新建模块后替换此区域)

jQuery的html()方法如何过滤script标签?剖析script注入攻击的防御机制

jQuery 的 html() 方法在前端开发中常被用来动态插入 HTML 片段。很多开发者会有一个误解:既然浏览器通过 innerHTML 插入的 <script> 不会自动执行,那么 jQuery 的 html() 应该也不会执行脚本。实际情况恰恰相反,jQuery 为了保证跨浏览器行为一致,会主动识别并执行字符串中的脚本。这个机制一方面让 html() 的行为更符合早期浏览器的预期,另一方面也带来了真实的 XSS 风险。要理解 jQuery 对 script 注入的防御机制,需要从它的 HTML 预处理、DOM 构建和脚本执行三段流程入手。

jQuery的html()方法如何过滤script标签?剖析script注入攻击的防御机制

下面先从整体的源码调用链看起。调用 $('#box').html(userInput) 时,字符串并不会直接进入目标元素的 innerHTML。jQuery 内部依次经过 html()、access()、domManip()、buildFragment() 和 clean() 等关键函数。每一步都有特定的职责,而 script 元素正是在这条链路上被单独收集和延迟执行的。

一、htmlPrefilter 与 DOM 构建阶段的隔离

在 jQuery 3.x 中,进入 buildFragment() 之前,HTML 字符串会先经过 jQuery.htmlPrefilter() 处理。这个方法的核心代码只有一行正则替换:

jQuery.htmlPrefilter = function( html ) {
    return html.replace( /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi, "<$1></$2>" );
};

这个正则会查找形如 <div/> 的非标准自闭合标签,并把它们改写成 <div></div>。它处理的不是脚本安全过滤,而是为了兼容 HTML 解析器对自闭合标签的差异。换句话说,htmlPrefilter 并不识别或删除 <script>,它只是保证后续用 innerHTML 或创建节点时标签结构正确。

真正让脚本暂时不执行的是 DOM 构建阶段。jQuery 通过 buildFragment() 创建一个 DocumentFragment,并在里面使用 jQuery.clean() 或内部遍历逻辑生成节点。当解析字符串得到 <script> 节点时,jQuery 会使用 document.createElement('script') 创建对应的脚本元素,设置其内容或 src,但它不会把该脚本节点直接插入活动文档。由于 DocumentFragment 本身不是文档树的一部分,此时脚本不会运行。这个设计是防御机制的起点:先把字符串转成节点,再将脚本节点从普通节点中分离,避免在构建过程中提前触发。

需要注意的是,这种隔离并不是安全过滤。脚本节点依然完整地保留在 scripts 数组中,只是执行时机被推迟到了 DOM 插入之后。如果开发者期待 jQuery 过滤掉恶意脚本,这里会失望。jQuery 的注释和文档也从未承诺 html() 具备 XSS 清理能力。

二、domManip 中的脚本收集与 DOMEval 执行

当 DocumentFragment 构建完成后,控制权回到 domManip()。这个函数先执行用户传入的回调,把 fragment 插入到目标元素中,然后开始处理收集到的脚本。源码中有一段核心逻辑:

jQuery.each( scripts, function( i, script ) {
    if ( script.parentNode ) {
        script.parentNode.removeChild( script );
    }
    if ( script.src ) {
        jQuery.ajax({
            url: script.src,
            dataType: "script",
            async: false
        });
    } else {
        jQuery.globalEval( script.textContent || script.innerText || script.text || "" );
    }
});

从这段简化代码可以看出,jQuery 对所有通过 html() 插入的脚本做了两件事:如果带有 src 属性,就通过 jQuery.ajax 以脚本类型同步加载执行;如果是内联脚本,就交给 jQuery.globalEval() 处理。这里没有白名单检查,也没有危险函数名的过滤,所有脚本都会被执行。

jQuery.globalEval() 在 3.x 中由 DOMEval() 实现,源码如下:

function DOMEval( code, doc ) {
    doc = doc || document;
    var script = doc.createElement( "script" );
    script.text = code;
    doc.head.appendChild( script ).parentNode.removeChild( script );
}

与直接使用 eval() 相比,这种做法的优势在于:脚本在全局作用域中执行,不会继承调用函数的局部变量;执行环境更接近浏览器正常加载的外部脚本;同时也规避了部分严格模式下的 eval 限制。但这仍然是一次真实的脚本执行,攻击者注入的内联脚本会拿到完整的全局对象,可以读取 cookie、发起请求、篡改页面。因此,DOMEval 不是防御手段,只是执行方式上的改进。

另一个容易混淆的点是:现代浏览器的 innerHTML 本身并不会执行插入的 <script> 标签。jQuery 的 html() 之所以会执行,是因为它在源码层面主动收集并调用了 DOMEval。这也是为什么在某些场景下,用原生 element.innerHTML = userInput 比用 $(element).html(userInput) 更不容易触发脚本执行。不过原生 API 同样不能防御事件属性注入,例如 <img src=x onerror=alert(1)>,所以不能简单认为换用 innerHTML 就安全了。

三、防御边界与不可信输入的安全处理

从上面的源码分析可以明确:jQuery 的 html() 方法没有提供任何内容过滤能力。它的所谓防御机制,主要体现在三段流程的隔离上——预处理修正标签结构、DocumentFragment 中的脚本延时、以及 DOMEval 的全局执行环境控制。这些措施是为了保证 DOM 构建和脚本执行的顺序可控,而不是为了阻止恶意脚本运行。如果开发者把未经处理的外部输入直接传给 html(),XSS 漏洞依然存在。

一个常见的攻击场景是:URL 参数中包含编码后的 <script> 标签,前端读取后调用 $('#content').html(decodeURIComponent(location.hash))。当 hash 为 #<script>alert(document.cookie)</script> 时,jQuery 会先解析出脚本节点,再通过 DOMEval 在全局执行,导致 cookie 泄露。此时即使页面使用了 HTTPS、设置了 HttpOnly 以外的防护,也难以阻止脚本读取同源数据。

要真正防御 script 注入,不能依赖 jQuery 的 html() 过滤逻辑。推荐的做法包括:对于纯文本展示使用 text() 方法,浏览器会自动转义 HTML 特殊字符;对于必须渲染富文本的场景,引入专门的 Sanitizer 库如 DOMPurify,在写入 DOM 前进行白名单过滤;对于用户提交的数据,服务端也需要做输出编码或存储过滤。如果项目已经使用 CSP 策略,可以配合 script-src 限制内联脚本执行,但这只能降低影响,不能替代输入过滤。

最后需要提醒的是,jQuery 的 html() 在读取模式返回的是元素的 innerHTML,里面可能包含用户之前注入的脚本标签,但这不会自动执行。真正危险的是写入模式。理解这一点后,开发者应该把 html() 视为一个强大的 DOM 操作工具,而不是安全边界。所有进入该方法的字符串都需要先经过安全审查,尤其是来自 URL、表单、第三方接口的内容。

广告位 · 文章底部(后台-模块管理替换此区域)