MathJax 大概是目前最普及的网页数学公式渲染方案你在技术博客、在线教育平台、问答社区里看到的 LaTeX 公式很大一部分都是它渲染出来的。但多数人把它当成“一个 JS 库”CDN 一引初始化配置一贴就算部署完了。问题是如果你把用户输入的公式直接丢给 MathJax等于把一段“可控的 HTML 生成逻辑”交给每一个访客——这正是 XSS 攻击与恶意注入最典型的高发场景。这篇文章是我基于某社区问答系统做公式安全加固的完整过程整理出来的。那个系统允许用户在提问和回答里写 LaTeX上线第一周就被人用公式投毒好在当时提前加了 CSP 兜底没有造成实际损失。之后我把整个方案收敛成三步输入侧净化、配置侧加固、输出侧兜底。三步做完我再也没在处理公式这个功能上收到过安全工单。方案适合所有开放用户公式输入的站点也适合想系统理解 XSS 防御原理的前后端开发者照着做就能复现。1. 问题的根源MathJax 为什么是 XSS 与恶意注入的温床1.1 渲染机制决定了输入必须被当作不可信数据MathJax 的工作流程看起来很简单扫描页面里的文本节点按定界符$...$、$$...$$、\(...\)把内容捞出来交给 TeX 解析器转成语义树再输出成 HTML 或 SVG 插入 DOM。问题恰恰出在最后一步。TeX 不是“只有数学符号”的静态语法里面有一批宏自带副作用。\href{地址}{文字}会生成一个a标签\class、\style、\bbox可以把任意 CSS 写进输出元素的属性早期版本的\unicode甚至支持直接输出一段 HTML 片段。这些宏在数学排版场景里很实用但在安全语境里它们就是用户可控的 HTML 生成接口。把一段不可信的 LaTeX 文本直接交给 MathJax就相当于允许每个访客向你的页面注入一段“带数学外衣”的 HTML。攻击者只要构造\href{javascript:alert(document.cookie)}{点我}这类载荷公式渲染完成后页面就多了一个可点击的脚本入口。这个入口和你工单系统里没做过滤的富文本编辑器本质上没有任何区别。1.2 攻击实际发生在哪个环节粗暴地把 XSS 归因于 MathJax 是不公平的真正的原因是“输入不可信 输出不设防”的组合。攻击发生有两个入口第一个入口是浏览器的 HTML 解析器。用户提交的内容在进入 MathJax 之前通常会被当作 HTML 的一部分插入页面。如果插入前没有转义或净化script、img onerror这类标签会在页面解析阶段直接执行跟 MathJax 一点关系都没有。这是所有富文本功能共有的问题。第二个入口才是 MathJax 自己的输出。MathJax 会把解析后的数学树序列化成 HTML如果配置允许某些危险宏输出的 HTML 里就可能携带事件属性、javascript 协议地址或者被 CSS 注入改变页面布局。早期 MathJax 2.x 还出过通过\unicode宏构造原始 HTML 执行脚本的已知漏洞官方修复方式是限制该宏只能接受合法 Unicode 字符。这个案例很适合说明公式渲染器的输入输出边界是整个安全链路上最容易被人忽视的一环。1.3 影响范围谁会成为受害者开放用户输入的一切站点都在名单上带评论区的技术博客、题库和在线作业平台、文档协作系统、内部论坛、工单系统。攻击者拿到一个 XSS 执行点之后可以盗取登录态、调用当前页面里的接口、篡改页面内容、伪造管理员操作。更麻烦的是XSS 通常在受害者浏览器里执行服务端日志看不到明显痕迹等你发现往往已经是批量盗号之后的事了。所以在实际项目里MathJax 部署绝不只是一段初始化脚本。下面三步是我最终沉淀下来的做法每步解决一个具体问题输入怎么洗、配置怎么收、输出怎么兜底。2. 防御第一步输入侧净化让恶意注入在入口处失效2.1 先想清楚你的系统允不允许富文本这一步在写代码之前就要决定。如果业务上只允许纯文本和数学公式安全模型会简单得多任何 HTML 标签都不保留所有用户输入先转成纯文本再进 MathJax。大多数社区类产品其实都能接受这个约束而且它带来的附带好处是页面结构稳定、样式不会被人随便破坏。如果业务上确实需要富文本比如用户要贴代码块、插图片那就把公式和富文本分开处理富文本统一走成熟的 HTML 净化库公式则在保存前单独做 LaTeX 层面的校验。不要试图在一个字段里既保留 HTML 又解析公式那是安全事件的高发设计。我见过一个项目为了省事把用户内容原样存库、原样插入再交给 MathJax结果一个img onerror就打通了整条链路。2.2 客户端的职责转义而不是信任浏览器端能做的事情有限但至少要保证“用户内容以文本形式进入 DOM而不是以 HTML 形式进入 DOM”。最简单的做法是使用textContent而不是innerHTML来承载用户内容配合定界符把其中的数学部分捞出来const container document.getElementById(math-container); container.textContent userInput; // 先把内容当纯文本放进去如果页面结构要求你先用 innerHTML 拼一段带公式的模板那就必须在拼之前把 HTML 特殊字符实体化function escapeHtml(raw) { return raw .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }这段代码的“为什么”值得说透script标签只有在被 HTML 解析器识别为标签时才会执行。转义成lt;scriptgt;之后它在页面上只是几个普通字符MathJax 后续会把它当作 LaTeX 文本解析即使解析出错也只是报一个数学公式错误不会形成脚本执行点。一个常见的误区是只在保存时过滤一次渲染时又用 innerHTML 拼回去等于把已经洗掉的脏东西又捡回来放到餐桌上。2.3 两层净化HTML 净化 LaTeX 白名单只做转义还不够因为用户可能提交的不是完整 HTML而是能利用 MathJax 输出机制的宏。所以第二步是给公式本身做一个白名单校验。先看 HTML 净化import DOMPurify from dompurify; // 适用于“公式 纯文本”场景 function sanitizeUserText(raw) { if (typeof raw ! string) { return ; } return DOMPurify.sanitize(raw, { ALLOWED_TAGS: [], // 不信任任何 HTML 标签 ALLOWED_ATTR: [], // 不允许任何属性 ALLOW_DATA_ATTR: false }); }然后是 LaTeX 层面的字符白名单。注意字符串层面的白名单不能直接限制宏因为宏名本身就是字母组合你没法判断\href和\frac哪个危险哪个安全。字符白名单的意义在于拦截明显异常的输入过长的公式、混入 HTML 标签、包含算式外的控制字符。const LATEX_SAFE /^[\\{}\[\]()\^_a-zA-Z0-9\s\\-\*/.,;:!?|∞≤≥≠±×÷∑∏∫√∂∇πθφψαβγλμστω]$/; function validateLatex(text) { const value String(text).trim(); if (value.length 5000) { throw new Error(公式长度超出限制); } if (!LATEX_SAFE.test(value)) { throw new Error(公式包含不允许的字符); } return value; }这套正则只是示意真正上线时还要结合业务允许的宏列表做二次校验比如明确允许\frac、\sqrt、\sum明确禁止\href、\unicode、\class。在服务端保存前跑一遍比在浏览器里过滤一万次都可靠。2.4 服务端必须同步净化前端净化只是用户体验层面的保险攻击者完全可以绕过页面直接向接口提交数据。所以服务端要把同一套规则再执行一遍HTML 净化用服务端库公式校验用同一套白名单。我在某项目里用的是 Node 服务端直接复用上文的正则再配合开源的 sanitize-html 库如果服务端是 Python可以用 bleachPHP 也有成熟的 HTML Purifier。选型的标准只有一个它是否允许你配置“只保留什么”而不是“过滤掉什么”。能配置保留项的白名单方案比黑名单方案安全得多因为黑名单永远追着攻击者跑白名单直接把门关上。服务端净化还有一个额外收益可以把“渲染后的公式 HTML”缓存起来。同一个公式被一万个人看到只渲染一次既省性能又减少攻击者不断变化载荷反复探测的机会。恶意注入的样本也能在服务端留一份日志供后续分析。3. 防御第二步配置侧加固裁剪宏包堵住后门3.1 版本是第一道门槛先把话说满还在用 MathJax 2.x 的老项目应当把升级到 MathJax 3.x 列入安全整改项。3.x 重写了输入和输出机制很多历史漏洞在架构上就被堵死了比如对\unicode的限制、对输出 DOM 的序列化方式。2.x 的维护周期早已结束新漏洞不会有官方修复。版本锁定也很重要。我看到过不少项目直接用 CDN 的 latest 链接这种做法等于把“我的页面会执行什么代码”的开关交给 CDN 的更新策略。一旦上游发布新版本哪怕只是修一个无关紧要的 bug也可能改变你页面的行为。正确做法是锁定到具体小版本并且把渲染脚本下载到自己控制的静态资源服务器这样发布流程可审计、可回滚。3.2 用 packages 参数裁剪可用宏包MathJax 3 的 TeX 输入支持通过packages配置控制加载哪些宏包。默认情况下会包含autoload、require这类“按需加载”扩展用户公式里出现\require{xxx}、\href、\bbox等宏时MathJax 会动态把对应扩展拉进来。动态能力在安全场景里都是风险你很难预知用户会触发哪个扩展更难保证那个扩展没有输出不可控 HTML 的路径。我的做法是显式声明白名单示例配置如下script window.MathJax { tex: { inlineMath: [[$, $], [\\(, \\)]], displayMath: [[$$, $$], [\\[, \\]]], packages: [base, ams, newcommand, noundefined], tags: none, processEscapes: true, processEnvironments: true }, options: { enableMenu: false, skipHtmlTags: [script, noscript, style, textarea, pre, code] } }; /script script async srchttps://cdn.example.com/mathjax/tex-chtml.js integrity这里填写官方发布的 SRI 哈希 crossoriginanonymous/script这里packages数组只保留数学排版必需的核心扩展没有把autoload和require放进去用户想用\require动态加载扩展就是不可能的。实际效果需要用测试页验证在裁剪后的环境里逐个输入\href、\unicode、\class、\style、\bbox观察它们是报“宏未定义”还是正常渲染。报“宏未定义”的就是我们想要的正确结果。3.3 业务需要的特殊能力单独放行如果业务上确实需要某些宏不要直接打开对应扩展而是把它“窄化”。以链接为例与其让用户写\href{任意内容}{文字}不如在编辑器里提供“插入公式链接”按钮用户只填链接目标和文字服务端校验目标地址必须是以http或https开头然后由服务端生成一个普通a标签与公式分开存储。这样可攻击面就收缩到一个可以写单元测试的校验函数里而不是散布在 MathJax 的宏解析逻辑中。另外一个常在裁剪时被忽略的选项是enableMenu: false。右键菜单本身不是漏洞但菜单里提供了从公式源代码复制、查看 MathML 等操作这些操作在某些自定义扩展下可能触发额外的代码路径。对纯展示型站点来说这个菜单没有存在必要关闭它是顺手的安全减肥。3.4 第三方插件和自定义宏的排查老项目里往往积攒了一堆自定义宏和第三方数学插件这些代码的维护质量参差不齐有的直接拼接innerHTML有的依赖已经被禁用掉的扩展。安全整改时要把所有自定义宏清单拉出来逐一过一遍凡是会输出 HTML 属性的宏要么重写为白名单校验后的输出要么直接禁用。不要迷信“这个宏用了三年都没出事”“没出事”通常只是攻击者还没注意到它。我排查时的一个习惯是把项目里的所有.js文件搜一遍innerHTML、insertAdjacentHTML、outerHTML三个关键词。搜索结果的每一处都值得问一句这里的输入从哪来、经过校验没有、输出给谁看。这种代码审计虽然枯燥但往往能在上线前揪出几个隐藏的注入点。4. 防御第三步输出侧与运行时兜底把漏网伤害降到最低4.1 最稳的方案服务端渲染成静态 HTML前两步把大部分攻击挡在门外但安全讲究纵深。第三步要解决的是“万一还有漏网”的问题。最彻底的办法是把公式渲染从浏览器搬到服务端用户提交内容时服务端用 MathJax 的 Node 版本把公式直接渲染成 HTML 字符串存进数据库。前端拿到的是已经渲染好的静态标记页面里根本不运行 MathJax 的解析逻辑。const { liteAdaptor } require(mathjax-full/js/adaptors/liteAdaptor.js); const { RegisterHTMLHandler } require(mathjax-full/js/handlers/html.js); const { TeX } require(mathjax-full/js/input/tex.js); const { HTML } require(mathjax-full/js/output/html.js); const adaptor liteAdaptor(); RegisterHTMLHandler(adaptor); const tex new TeX({ packages: [base, ams, newcommand] }); const html new HTML({}); function renderLatex(source) { const node tex.parse(source); const markup html.render(node); return adaptor.outerHTML(markup); } // 渲染结果再走一次 HTML 净化作为最后保险 const safeMarkup sanitizeHtml(renderLatex(x^2 y^2 z^2));服务端渲染的本质是把“在每个访问者浏览器里执行解析逻辑”变成“只在你的受控环境里执行一次”。攻击面从 N 个未知浏览器收敛到一个你可以打补丁、看日志、做审计的 Node 进程。代价是发布链路多一步但对有用户输入内容的站点来说这笔账很划算。如果不想引入太重的基础设施折中方案是发布时渲染加缓存而不是每个请求都现算。4.2 CSP浏览器层面的最后一道闸门即使前面全被绕过CSP 仍能限制脚本的执行。CSP 的核心思想是白名单告诉浏览器这个页面只能加载哪些来源的资源、执行哪些脚本。如果一个 XSS 载荷注入了外部脚本而 CSP 没有放行这个域名脚本会被浏览器直接拒绝。一个适用于 MathJax 场景的 CSP 配置长这样Content-Security-Policy: default-src self; script-src self https://cdn.example.com nonce-随机字符串; style-src self unsafe-inline; img-src self data:; font-src self data:; object-src none; base-uri none; frame-ancestors none;解释几个关键点script-src使用 nonce一次性随机数而不是unsafe-inline内联脚本只有带正确 nonce 才能执行。这样即使攻击者能把script标签注入页面也拿不到正确的 nonce。style-src unsafe-inline看起来违反直觉但 MathJax 的 CHTML 输出依赖元素上的内联样式完全禁止会让公式渲染错乱。这是当前版本下的妥协将来如果改用 SVG 输出或迁移到其他渲染器可以再收紧。object-src none、base-uri none、frame-ancestors none是对插件、base 标签劫持、点击劫持的通用加固跟 MathJax 无关但既然要加 CSP 就一次性收紧。CSP 的投放建议分两步先以Content-Security-Policy-Report-Only模式灰度观察收集违规报告确认没有误伤正常功能后再切换为强制模式。我见过不少项目一上来就上强制 CSP结果把埋点脚本、第三方字体全部拦死最后只能回滚白白浪费了半天工时。4.3 客户端渲染时的运行时扫描如果因为历史包袱必须保留客户端渲染比如老页面大量依赖动态内容至少可以在渲染完成后对容器做一次 MutationObserver 扫描把不合规的节点摘除const MATH_ROOT document.getElementById(math-container); const unsafeSelector script, iframe, object, embed, [onerror], [onload], [href^javascript:], [href^vbscript:]; const observer new MutationObserver((mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType 1 node.matches(unsafeSelector)) { node.remove(); console.warn(移除疑似恶意注入节点:, node); } } } }); observer.observe(MATH_ROOT, { childList: true, subtree: true });这段代码不是银弹因为 CSS 注入和纯视觉的钓鱼覆盖不会被它拦截但它能把“公式渲染后意外出现可执行节点”这类事故变成可观测、可报警的事件。加上日志之后你还能在后台看到攻击者的载荷特征用来反哺前两步的拦截规则。运行时扫描的价值不在消灭所有漏洞而在于让漏网之鱼不至于变成数据泄露事故。5. 常见问题与排查技巧实录5.1 症状对照表症状可能原因处理方法用户提交带公式的内容后公式区域空白MathJax 解析异常通常是公式超长或包含禁用的宏服务端限制公式长度并在 MathJax 里打开错误日志定位具体宏公式里的\require{...}报宏未定义裁剪 packages 后按需加载被关闭这是预期行为。需要的能力在 packages 里显式开启不要允许用户动态加载扩展控制台出现Refused to execute inline scriptCSP 的 script-src 没有给内联脚本放行给 MathJax 的 script 标签加 nonce不要直接开 unsafe-inline富文本里的iframe、object能插入页面输出侧没有做 HTML 净化所有富文本统一过 DOMPurifyALLOWED_TAGS 里排除 iframe/object/embed公式能显示但页面布局被改得乱七八糟用户公式里带了\style、\class之类的 CSS 注入宏检查 packages 是否放行了对应扩展在输入白名单里禁止这些宏某些项目符号、矩阵环境不能渲染裁剪掉了一些必要的宏包对照官方文档的宏包列表按业务需要显式加回5.2 手测载荷清单在测试页里逐个输入下面的公式然后打开浏览器开发者工具检查输出 DOM确认是否存在可点击的 javascript 链接、事件属性、异常标签$\href{javascript:alert(1)}{点我}$ $\unicode{img srcx onerroralert(1)}$ $\class{red-text}{x}$ $\style{position:fixed;left:0;top:0}{x}$ $\bbox[red]{x}$ $\require{color}$ $scriptalert(1)/script$判断标准很简单只要输出 HTML 里出现hrefjavascript:...、onerror、script、iframe中的任意一种就说明当前部署对恶意注入是敞开门的。把这份清单写进自动化测试每次升级前端依赖或调整 MathJax 配置时跑一遍能有效防止安全配置被后续变更悄悄改回去。5.3 排查步骤遇到疑似被注入的问题我一般按这个顺序排查先复现。用真实用户的输入在本地测试页里跑复现不了就去看服务端保存的原始字段确认是存储数据的问题还是渲染时的问题。缩小范围。在空白页面里只引 MathJax把可疑公式手动贴上。如果空白页也出问题问题在 MathJax 配置如果空白页正常问题在你的业务渲染逻辑。看网络。打开 Network 面板观察页面加载过程里有没有请求陌生域名。XSS 通常需要回连攻击者服务器异常域名请求是最明显的特征。看控制台。启用 Preserve log清理所有日志后复现任何Refused to execute、SecurityError异常都值得追。留痕。把可疑载荷存下来连同来源用户、提交时间、IP 一起记录。安全事件最怕没有日志后续防护规则的调整全靠这些样本。5.4 容易忽略的三个细节第一个细节是搜索索引。很多站点的公式内容会被搜索引擎或内部搜索系统抓取抓取的 HTML 片段如果包含被注入的链接会影响整站的信誉甚至被浏览器标记为恶意站点。所以公式渲染结果必须和正文页面走同一套安全基线。第二个细节是 CDN 域名本身。如果站点把 MathJax 托管在第三方 CDN且 CSP 放行了那个 CDN 域名那么 CDN 被入侵或配置错误就等同于全站脚本失控。采购 CDN 时把安全资质和供应链审计问清楚最好让自己人托管一份并锁定哈希。供应链攻击这两年越来越常见公式渲染脚本虽然不起眼但它和业务脚本享有同等的执行权限。第三个细节是错误信息泄露。MathJax 打开调试模式后会在控制台输出解析错误详情这些信息在开发阶段很有用上线前必须关闭否则等于免费给攻击者提供内部结构侦察。开发环境随便开生产环境一律enableMenu: false错误提示只显示“公式解析失败”。6. 可直接复制的完整部署模板与上线前检查清单6.1 完整模板下面是整合了上述三步的最小完整页面。需要根据你自己的域名、nonce 生成方式和 SRI 哈希做替换。!DOCTYPE html html langzh-CN head meta charsetutf-8 meta http-equivContent-Security-Policy contentdefault-src self; script-src self https://cdn.example.com nonce-替换为随机值; style-src self unsafe-inline; img-src self data:; font-src self data:; object-src none; base-uri none; frame-ancestors none script nonce替换为随机值 window.MathJax { tex: { inlineMath: [[$, $], [\\(, \\)]], displayMath: [[$$, $$], [\\[, \\]]], packages: [base, ams, newcommand, noundefined], tags: none, processEscapes: true, processEnvironments: true }, options: { enableMenu: false, skipHtmlTags: [script, noscript, style, textarea, pre, code] } }; /script script async nonce替换为随机值 srchttps://cdn.example.com/mathjax/tex-chtml.js integrity替换为资源实际 SRI 哈希 crossoriginanonymous/script /head body div idmath-container/div script nonce替换为随机值 // 用户内容先净化成纯文本再交给 MathJax const rawUserInput 已知 $x^2 \\frac{1}{2}$ 无解; const safeText sanitizeUserText(rawUserInput); const container document.getElementById(math-container); container.textContent safeText; MathJax.typesetPromise([container]).catch(function (err) { console.warn(公式解析失败, err); }); /script /body /html实际接入时sanitizeUserText 请使用 DOMPurify 等成熟库的实现别用自己拍脑袋写的正则。HTML 净化是最容易写错、也最不该自己实现的部分。6.2 上线前安全检查清单检查项预期结果完成MathJax 版本3.x 具体版本无 latest 标签渲染脚本来源自有静态资源服务器带 SRI 哈希CSP 响应头script-src 用 nonceobject-src 为 none服务端 HTML 净化用户内容经白名单库净化后入库LaTeX 白名单校验公式长度限制 危险宏禁用packages 配置只含 base/ams 等必需扩展手动载荷测试上文 5.2 节清单全部无执行自动化回归XSS payload 作为测试用例纳入 CI错误日志生产环境关闭调试输出保留安全日志这份清单我每次接入新项目都会打印一份贴在工位旁边。别嫌它啰嗦公式渲染这个功能太容易被低估了几乎所有踩过坑的人都是在“只差一个功能应该没事”的乐观里翻车的。最后再分享一个实际经验那套社区问答系统安全加固跑通之后我还顺手做了一个改进——把公式渲染结果按内容哈希缓存到对象存储里。同一个公式被用户反复引用时前端直接从缓存拿静态 HTML既省流量又绕开了大部分重复解析的风险。这个思路同样适合你们凡是涉及用户输入再输出的功能能静态化就静态化能在服务端做校验就在服务端做校验最后才轮到浏览器里的各种防御。思路对了安全不是一个需要时刻提心吊胆的附加项而是系统设计里顺理成章的一部分。
阅读完成 · 觉得有帮助?