XSS 和 CSRF:从一张安全工单把前端防线重新拉齐
一张弹着 alert 的后台截图,足以说明 XSS 从来不是“前端展示脏一点”这么简单。只要用户提交的内容能在运营后台里执行脚本,买家就等于拿到了往高权限账号浏览器里塞代码的入口。
这次问题出在评价管理页。页面用 v-html 渲染用户提交的富文本评价,功能上看只是支持加粗、换行、贴图,安全上却等于把一段未经处理的 HTML 直接交给了浏览器。复现步骤也很直白:App 端提交带特定内容的评价,运营在后台打开列表,脚本就在她的浏览器里执行。
后面会从这个现场出发,把存储型 XSS、浏览器执行边界、内容净化、CSRF 和前端侧常见防线一次串起来看。
脚本到底是怎么跑起来的
先在测试环境复现。我模仿安全同学的手法提交了一条评价,内容是最经典的:
1<script>alert(document.cookie)</script>
后台一打开——什么都没发生。实习生说是不是已经修好了,我说别急,这里有个很多人都搞错的知识点:通过 innerHTML 插入的 <script> 标签,浏览器规范规定不会执行。v-html 底层就是 innerHTML,所以这个最出名的 payload 反而是打不进来的。
真实的攻击 payload 长这样:
1<img src=x onerror="alert(document.cookie)"> 2<svg onload="alert(1)"> 3<a href="javascript:alert(1)">点我领券</a>
我把评价内容换成第一条,后台一刷新,alert 稳稳地弹了出来。onerror、onload 这些事件处理器是会触发的,javascript: 伪协议也会。实习生在旁边感慨「原来不用 script 标签也行」,我说这正是要害——别以为过滤了 <script> 就安全了,能执行 JS 的写法比你能想到的多得多,这也是为什么手写黑名单过滤基本拦不住。
顺便给实习生捋了 XSS 的三个分类,定位问题时很有用:
存储型,恶意内容存进了数据库,比如这次的评价、还有昵称、签名,影响所有打开页面的人,危害最大——我们中的就是这种。反射型,恶意内容在 URL 参数里,需要诱导用户点链接才触发。DOM 型,漏洞完全在前端 JS 里,比如把 location.hash 直接塞进 innerHTML,请求都不经过后端,后端的过滤全部失效。前端最该警惕的其实是 DOM 型,因为它绕过了服务端的所有关卡,纯粹是前端自己挖的坑。
为什么当初的「后端过滤」没兜住
我第一反应是去问后端同事:「评价入库时不是过滤过吗?」他把过滤代码发给我看——一段正则,把 <script> 标签删掉。
问题就出在这。一是前面说了,真正的 payload 根本不用 <script>;二是这种正则删除本身就能被绕过,攻击者写一个 <scr<script>ipt>,正则删掉中间那段,剩下的碎片反而拼成了完整的 <script>。他看完自己都笑了。
所以修复方案我们分了两层。后端在入库时收紧校验,我在前端渲染时做白名单过滤。白名单过滤不要自己手写,正经做法是用成熟的库,2019 年前端这边的标准答案是 DOMPurify:
1import DOMPurify from 'dompurify' 2 3const clean = DOMPurify.sanitize(userHtml, { 4 ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'br', 'ul', 'li', 'img'], 5 ALLOWED_ATTR: ['href', 'src'] 6}) 7container.innerHTML = clean
它基于白名单解析 DOM 树,把不允许的标签、所有 onxxx 事件、javascript: 协议全部清掉,比正则可靠得多。评价渲染那块我包了一个过滤方法,所有走 v-html 的数据必须先过它。
这里要展开说说 v-html。Vue 的 {{ }} 插值默认转义,是安全的;但 v-html 等价于 innerHTML,直接渲染原始 HTML:
1<!-- 危险:等于 innerHTML --> 2<div v-html="userComment"></div>
修复上线后,我在组里的 code review 规范里加了一条:看到 v-html 必须问一句「这数据哪来的、过滤了没」。React 那边对应的是 dangerouslySetInnerHTML,名字起得好,dangerously 就是在提醒你。
至于不需要富文本的地方——昵称、搜索词、地址——一律当纯文本渲染:
1element.textContent = userInput
还有一条经验这次体会最深:不要相信「后端已经处理过」。后端按入库时的规则过滤,前端可能换了种渲染方式,历史数据里的脏内容照样触发。安全要做纵深防御,输入做校验、输出做转义,两边都做,别指望一道关卡兜底。
URL 参数也是一个口子
工单修完,我不放心,把整个后台项目全局搜了一遍 innerHTML 和 v-html。果然又搜出一处,是搜索结果页的一段老代码:
1var keyword = location.search.replace('?q=', '') 2document.querySelector('.keyword').innerHTML = keyword
URL 里带恶意内容,就可能被执行——这就是前面说的 DOM 型 XSS,后端全程无感知。改成 textContent 就稳了:
1document.querySelector('.keyword').textContent = keyword
顺手说个解析参数的小坑:那段 location.search.replace('?q=', '') 本身就不严谨,参数顺序一变、多个参数并存时就解析错了,而且没有 decode。我统一改成了 URLSearchParams:
1const params = new URLSearchParams(location.search) 2const keyword = params.get('q') || '' 3document.querySelector('.keyword').textContent = keyword
它自动处理 URL 解码和多参数,再配合 textContent 输出,既正确又安全。location.hash、document.referrer 这些同样是外部可控的,往 DOM 里塞之前都要当不可信数据处理。只要是外部输入,默认不可信——这句话我让实习生抄在了笔记本第一页。
给全站加一道墙:CSP
光靠转义,难免有遗漏——这次不就漏了两处吗。所以修复之外,我给后台加了一层 CSP(内容安全策略)作为浏览器层面的兜底:就算哪天又有一处 XSS 漏过去,CSP 也能拦住脚本执行。用响应头下发:
1Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
这条策略的意思是:脚本只允许加载同源的,禁止内联脚本(onerror、<script>alert(1)</script> 这些全废了),禁止 object/embed。配上之后,攻击者注入的内联事件处理器根本跑不起来。
代价是项目里原有的内联脚本、内联事件、eval 都得改掉,老项目接入有改造成本。我的做法是先用 Content-Security-Policy-Report-Only 模式跑了一周,只上报不拦截,看看会误伤哪些东西——果然揪出一个第三方统计脚本和两处历史内联代码——处理完再切成强制模式。这道墙值得花时间砌。
顺带提一句浏览器自带的 X-XSS-Protection 响应头,它是老一代的反射型 XSS 过滤器,能力有限、误报不少,社区已经在讨论废弃它了。有 CSP 之后不必指望它,但老项目见到这个头,知道它是干什么的就行。
第四问:如果脚本真跑起来了,它能偷走什么
实习生问了个好问题:那个 alert(document.cookie),说明脚本能读 cookie,那登录态不就直接被偷走了?
这取决于 token 存在哪。如果存在普通 JavaScript 可读的位置,XSS 成功就等于交出登录态。Cookie 可以设置三个安全属性,各管一摊:
HttpOnly:document.cookie读不到它,XSS 拿不到这个 cookie。Secure:只在 HTTPS 下发送,防止明文传输时被中间人嗅探。SameSite:限制跨站请求时是否带上 cookie,这是防 CSRF 的关键,下面展开。
我们后台的 session cookie 当时没配 HttpOnly,这次一并让后端加上了。加完再弹 alert(document.cookie),登录态那条已经看不见了。
这里有个绕不开的权衡:token 到底存哪。存 HttpOnly Cookie,抗 XSS(脚本读不到),但浏览器会自动带上它,所以怕 CSRF,得另配 SameSite 或 CSRF Token。存 localStorage,前端读写方便、不会被自动发送(不怕 CSRF),但任何 JS 都能读,一旦 XSS 整个 token 直接被偷。我现在的倾向是:登录态走 HttpOnly Cookie + SameSite;如果架构上必须前端拿 token(纯前后端分离、跨域多端),那就要格外重视 XSS 防护,因为这时候 XSS 等于直接交出登录态。没有银弹,关键是想清楚自己堵的是哪个攻击面。
另外提醒一句:前端别把敏感 token 随手放进页面变量,打日志、上报错误时也要小心,别把 Authorization 头、cookie 内容一股脑塞进监控平台——那等于换了个地方泄露。
一个反问:那 CSRF 呢
修复评审会上,后端同事反过来问我:「XSS 堵上了,CSRF 你们前端管不管?」我借机把这块也理了一遍。
CSRF 是利用用户登录态,诱导浏览器向目标网站发送请求。比如运营同学登录着我们后台,攻击页面里放一个自动提交的表单:
1<form action="https://admin.example.com/coupon/create" method="POST"> 2 <input name="amount" value="9999"> 3</form> 4<script> 5 document.forms[0].submit() 6</script>
如果后台只靠 Cookie 判断登录,浏览器会自动带上 Cookie,这张大额优惠券可能就真发出去了。
CSRF 的核心要害就一句话:浏览器在发跨站请求时,会自动带上目标站点的 Cookie。攻击者不需要知道你的 cookie 是什么,他只是借你浏览器的手发了个请求,cookie 是浏览器自动贴上去的。所以 CSRF 偷不走你的登录态,但能冒用你的登录态干事——和 XSS 的攻击面完全不同。
理解这点就明白,为什么纯 Cookie 鉴权最怕 CSRF,而把 token 放请求头里手动带的方案天然抗 CSRF——攻击者的页面读不到你的 token,伪造的请求缺了那个头,服务端一看就拒。
CSRF 防护:我们最后落的方案
常见方式有四种:CSRF Token、SameSite Cookie、校验 Referer 或 Origin、重要操作二次确认。
CSRF Token 最经典:服务端下发一个随机 token,前端每次提交带上,服务端比对。它的可靠性来自「攻击者跨站读不到这个 token」。前端接入很简单:
1axios.post('/api/coupon/create', data, { 2 headers: { 3 'X-CSRF-Token': csrfToken 4 } 5})
实际接入时要注意 token 怎么传给前端——常见做法是后端放进一个非 HttpOnly 的 cookie,前端读出来放请求头。axios 对这套机制有默认支持,就是 xsrfCookieName / xsrfHeaderName:
1// axios 默认会读名为 XSRF-TOKEN 的 cookie,自动放进 X-XSRF-TOKEN 头 2const service = axios.create({ 3 xsrfCookieName: 'XSRF-TOKEN', 4 xsrfHeaderName: 'X-XSRF-TOKEN', 5 withCredentials: true 6})
SameSite Cookie 是这两年我更推荐的打底方案,浏览器层面直接挡住跨站带 cookie,几乎零业务改造。SameSite=Lax 能挡住大部分跨站 POST;Strict 更严,但从外站链接跳进来时登录态会丢(别人发你站点链接,点进去发现没登录)。我们最后选了 Lax,体验和安全比较平衡:
1Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax
校验 Referer/Origin 是补充手段,但 Referer 可能因为隐私设置被浏览器删掉,不能当唯一依据。二次确认(删除弹确认、发券输密码)属于业务层兜底,挡不住自动化请求但能拦住一部分。
我们最后的标配是:SameSite=Lax 打底,发券、退款这类重要写操作再叠一层 CSRF Token,双保险。
又揪出一个:GET 删除接口
排查 CSRF 时我顺手翻了下接口列表,发现一个历史遗留:
1GET /api/comment/delete?id=1
删除操作走 GET。我把它标成高危发给后端,对方起初觉得「内部后台没人攻击吧」,我举了个例子:GET 能被各种东西无声触发——页面里一个 <img src>、一段 <link>、预加载、爬虫、甚至 IM 软件抓链接生成缩略图,都会发 GET。攻击者在任何地方贴一张图 <img src="https://admin.example.com/api/comment/delete?id=1">,登录态还在的运营同学刷到那个页面,删除就执行了,全程不用点任何东西。
GET 应该是「安全且幂等」的——只读、不改状态,这是 HTTP 语义的基本约定,不只是安全要求。后端同事听完当天就把接口改成了 POST。
不过我也提醒他:把危险操作改成 POST 不等于就安全了,POST 照样能被自动提交的表单触发(前面那个发券的例子就是),所以 POST + CSRF 防护才是完整的,换方法只是第一步。
两个顺手加固的延伸点
既然安全部门盯上了我们,我索性把相邻的两个点也一起处理了。
一是点击劫持。攻击者把我们的后台页面用透明 iframe 盖在诱导页面上,用户以为在点「领奖」,实际点的是 iframe 里的「删除」按钮。防御很便宜,加一个响应头禁止被嵌套:
1X-Frame-Options: SAMEORIGIN
CSP 里对应的写法是 frame-ancestors 'self',我们两个都配了。
二是我们后台确实有个合法的 iframe 场景——嵌了一个第三方物流查询页。反过来想,它也是外部内容,给它加上 sandbox 属性限制能力:
1<iframe src="https://logistics.example.com/track" sandbox="allow-scripts allow-same-origin"></iframe>
按需开白名单,不给的能力(弹窗、表单提交、顶层导航)一律禁止。外部内容进我的页面,和用户输入进我的 DOM,是同一类问题。
工单关闭那天
周五下午,安全部门复测通过,工单关闭。我在组内周会上把整个过程讲了一遍,最后给大家(主要是说给实习生,也是说给自己)留了几条压舱的规矩:
渲染任何用户内容默认用 textContent,要 HTML 就过 DOMPurify;v-html 一律重点 review;登录态优先 HttpOnly Cookie 配 SameSite;重要页面加 CSP 兜底;写操作不用 GET,并叠 CSRF 防护;外部输入——评价、昵称、URL 参数、iframe 内容——全部默认不可信。
XSS 关注脚本注入,CSRF 关注冒用登录态发请求,两者攻击面不同,防法也不同,但底层是同一种意识:你信了你不该信的输入,就会出事。前端不能单独解决安全问题,但输出转义、请求层带 token、危险操作确认、错误信息不外泄,这些是我们能守住的阵地。
安全这块原本一直被我往后放,这张工单把它提到了最前排。评价管理页那个存储型 XSS 修完之后,我们又照着这套清单把后台其他几处 v-html 和历史接口过了一遍,目前没再发现类似的口子。