Appearance
XSS 攻坚战
背景:settings.ts 有数十处 innerHTML 拼接用户输入——软件名称、路径、参数都可能被注入恶意脚本。 过程:攻击面分级(城门/护城河/内墙)+ addToggleRow/addSliderRow label 改用 textContent + 软件管理页面 7 处转义。先堵 80%,剩下 20% 后补。
外交官揣着那颗红石子,站在了设置之城的城门前。
settings.ts——1148 行的巨型堡垒。界面设置、系统设置、外部库、软件管理、性能模式……十多个子菜单,层层叠叠像一座迷宫。而 XSS 的种子,散落在迷宫的每一个角落。
"数十处 innerHTML,"AI 同行者站在他身边,仰望着城墙,"全改了?"
外交官摇摇头:
"不。先分清哪些是城门,哪些是内墙。"
攻击面分级
外交官在城门前的空地上铺开一张大纸,上面画着 settings.ts 的平面图。
然后他拿起红笔,在某些位置画了圈。
"第一类:城门。 用户可以直接输入内容的地方。"他用红笔在"软件管理"那一块画了个粗圈,"用户输入软件名称、输入命令行参数、输入路径。这些字符串如果被 innerHTML 渲染,就是真的 XSS。"
"第二类:护城河。 中间层函数的参数。比如 addToggleRow 的 label、addSliderRow 的 label。它们本身不是用户输入,但它们的调用方可能传入用户输入。如果中间层不设防,某一天某个调用方传了用户输入,就破防了。"
"第三类:内墙。 后端已校验的数据。SoftwareEntry、ExternalPath——这些是 Go 后端读配置文件出来的,格式固定,用户改不了。innerHTML 拼接它们风险很低。"
AI 同行者数了数:
"所以……先打城门和护城河?内墙先不管?"
"对,"外交官把红笔放下,"XSS 加固不是'把所有 innerHTML 都干掉'——那是过度工程。XSS 加固是'把用户能碰到的路径都戴上手套'。攻击面小了,风险就可控了。"
他从口袋里掏出那颗红石子,放在纸的角落。
"先拿护城河开刀。护城河守住了,城门的压力就小一半。"
第一个缺口:addToggleRow
外交官选择的第一个突破口是 addToggleRow。
这是 settings.ts 里最常用的工具函数之一——构建一行带开关的条目。几十个菜单项都调用它。如果它的 label 参数能被 XSS 注入,那几十个地方同时破防。
他翻开代码,看到了这样一行:
typescript
row.innerHTML = `<div class="slide-label">${label}</div>`;一行。就一行。
label 从参数进来,直接拼进 innerHTML。如果 label 是 "<img src=x onerror=alert(1)>",它就是真的 img 标签。
外交官没有直接删了重写。他先做了一件事——查调用方。
"先确认有多少调用点,"他说,"以及每个调用点传的 label 是什么。"
grep 结果出来了。23 个调用点。
其中:
- 17 个传的是硬编码字符串:
"自动保存"、"阴影"、"性能模式"……不可能被注入 - 4 个传的是配置项名称,来自 Go 后端的配置结构
- 2 个……是软件管理的条目名称,来自用户输入
"23 个调用点里只有 2 个有实际风险,"AI 说,"那直接在那两个调用点转义不就行了?"
外交官摇摇头。
"不行。函数的契约不对。"
"契约?"
"addToggleRow 的 label 参数,按照它现在的实现,是'HTML 字符串'——你传什么它就原封不动插进去。但调用方呢?调用方以为它是'文本标签'。两边的理解不一样。"
他用手指敲了敲那行代码:
"今天调用方传的是安全的,明天新来的人不知道,传了个用户输入的字符串——bug 就来了。函数本身的契约是错的,它的名字叫 'addToggleRow',参数叫 'label',但行为是 'innerHTML 注入'。这是一个陷阱。"
"所以……改函数本身?"
"改函数本身,"外交官点头,"把 label 当纯文本处理。调用方不需要知道里面是 innerHTML 还是 createElement。函数应该对自己的参数负责。"
戴上手套
外交官把那一行 innerHTML 拆成了三行:
typescript
const labelEl = document.createElement('div');
labelEl.className = 'slide-label';
labelEl.textContent = label;
row.appendChild(labelEl);三行代替一行。多了两个变量,多了一次 appendChild。但 label 现在是 textContent——浏览器会自动把所有 HTML 特殊字符转义。< 变成 <,> 变成 >,& 变成 &。
再多的恶意脚本,到了 textContent 手里,都只是一段文字。
"戴上手套了,"外交官说。
然后他顺手改了 addSliderRow——同样的模式,label 参数同样是直接拼 innerHTML。同样的问题,同样的解法。
"为什么不把 row.innerHTML 整个替换掉?"AI 指着 addToggleRow 里的另一处——那个开关按钮也是用 innerHTML 拼的。
"因为开关按钮的 HTML 是硬编码的,没有外部变量,"外交官说,"固定的字符串不存在注入风险。全改成 createElement 当然更'干净',但代价是代码膨胀、可读性下降。安全加固不是为了把代码写成某种'政治正确'的样子,是为了堵漏洞。"
"够用就好?"
"够用就好,"外交官确认,"过度防御的成本和防御不足的成本一样高。"
城门:软件管理
护城河守住了,外交官转向真正的城门——软件管理。
这是 settings.ts 中用户输入最多的地方。用户可以添加软件、修改软件名称、修改命令行参数、修改图标。这些字符串最终都会出现在 UI 上。
他翻到 buildSoftwareDetailLevel——软件详情页。
几十个 innerHTML。有的显示名称,有的显示路径,有的显示参数。大部分来自 SoftwareEntry——Go 后端的数据结构。
"等等,"AI 皱起眉,"SoftwareEntry 不是后端的吗?用户不能直接改?"
"用户可以通过 UI 添加软件,"外交官说,"添加的时候输入名称,名称保存到配置文件,配置文件由 Go 读写。所以 SoftwareEntry 的 name 字段——本质上还是用户输入。"
"Go 端不做转义吗?"
"Go 端只负责存储,"外交官摇摇头,"它不知道这些字符串最终会被拼进 innerHTML。转义是渲染层的责任——谁渲染,谁转义。这是分层的基本原则。"
他开始逐个检查软件管理页面的 innerHTML:
- 软件名称显示 → 用户输入 → 需要转义 ✋
- 软件路径显示 → 用户输入 → 需要转义 ✋
- 命令行参数显示 → 用户输入 → 需要转义 ✋
- 图标名称显示 → 固定枚举 → 安全 ✅
- 分区标题 → 硬编码 → 安全 ✅
一圈下来,七处需要改。
"七处,"AI 数了数,"不算多。"
"多的还在后面,"外交官合上那一页,"软件列表、外部库列表、下载目录列表……每个列表项都是 innerHTML 拼出来的。但那些数据也来自 Go 后端的配置——和 SoftwareEntry 一样的情况。"
"那全改?"
外交官沉默了一会儿。
"不。"
"为什么?"
"因为……"他顿了顿,"攻击面小。软件名称、路径、参数——这些是用户自己输入的,用户自己注入自己看,危害有限。真正危险的是'别人输入的内容我来看'——比如从网上下载的模型名称、共享的预设名称。那些才是 XSS 的重灾区。"
他用红笔在清单上勾了三个:名称、路径、参数。
"先改这三个。这是用户最常输入的字段。其他的——图标、分类、备注——后面再说。"
"那不是还留着漏洞吗?"
"是留着,"外交官承认,"但安全是有成本的。今天的预算是半天,半天能堵 80% 的攻击面,就先堵 80%。剩下 20% 放后面。完美主义是安全的敌人——因为追求 100% 而迟迟不动手,等于 0%。"
看不见的防线
改完软件管理的三处,外交官停下来喝了口水。
AI 同行者翻着改后的代码,忽然问:
"你有没有发现一件事?"
"什么?"
"我们改了这么多 innerHTML,其实大部分都不会真的被注入。"
"对,"外交官点头,"绝大多数用户输入就是正常的文字。软件名就是'Blender',路径就是'C:\Program Files\Blender',没有尖括号,没有 script 标签。"
"那我们做的事情……有意义吗?"AI 有点困惑,"花了这么多时间改了几十处,可能一次都不会用到。"
外交官放下水杯,看着他。
"你知道消防栓吗?"
"消防栓?"
"每条街上都有消防栓。大多数消防栓一辈子都不会被打开一次。但是你不会因为'可能用不上'就不安消防栓。"
他指了指屏幕上那些 textContent:
"安全防线就是这样——你看不见它,因为它没有被触发。但它在那里。真有一天出事的时候,它就是那道拦住洪水的门。"
AI 想了想,点点头。
"那……我们怎么知道防线有没有用?"
"不知道,"外交官笑了,"最好的安全措施就是你永远不知道它有用没用——因为它从来没被触发过。"
第一批战果
半天过去,第一批 XSS 加固完成了。
外交官在审计报告上勾掉了几个勾:
- ✅ addToggleRow / addSliderRow 的 label 参数:改用 createElement + textContent,23 个调用点同时受益
- ✅ 软件详情页名称显示:textContent 替代 innerHTML
- ✅ 软件详情页路径显示:textContent 替代 innerHTML
- ✅ 软件详情页参数显示:textContent 替代 innerHTML
剩下的:
- ⏳ 软件列表项名称(innerHTML,来自 Go 配置,攻击面中)
- ⏳ 外部库列表项名称(innerHTML,来自 Go 配置,攻击面中)
- ⏳ 下载目录列表(innerHTML,来自 Go 配置,攻击面低)
- ⏳ 几十个硬编码 innerHTML(无注入风险,纯代码风格问题)
"先到这里,"外交官合上笔记本,"第一波攻击结束。城门和护城河守住了,内墙后面再说。"
他站起来,活动了一下肩膀。
"下一颗红石子是什么?"AI 问。
外交官从口袋里掏出第二颗红石子——上面刻着 "loadPMXFile"。
"预设系统的脆弱路径匹配,"他说,"那个更有意思。"
他把第一颗红石子——XSS——放在桌上,和其他已经处理完的石子摆在一起。
红色稍微淡了一点。不是消失了,是——被处理过的颜色。
附录:XSS 加固原则
| 原则 | 说明 |
|---|---|
| 攻击面分级 | 用户输入 > 中间层参数 > 后端已校验数据。先堵城门,再修内墙 |
| 函数自负责 | 参数的转义是函数的责任,不是调用方的责任。函数名和行为要一致 |
| 够用就好 | 不追求 100% 消除 innerHTML,先堵住 80% 的攻击面 |
| 谁渲染谁转义 | 转义是渲染层的责任。后端存原始数据,前端渲染时转义 |
| textContent 优先 | 纯文本一律用 textContent,让浏览器做转义。不要自己写 escapeHtml 除非必要 |
教训:安全不是一堵墙,是一层一层的滤网。最外面的孔最大,越往里面孔越小。你不需要在最外层就拦住所有东西——你只需要确保最里面那层足够密。