ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端安全实战指南:XSS、CSRF、点击劫持与供应链攻击防御

前端安全实战指南:XSS、CSRF、点击劫持与供应链攻击防御

1. 前端安全到底在防什么

前端安全这个词,刚入行的同学听起来容易觉得虚。页面能跑、按钮能点、接口能通,不就完了吗?但真正做过线上项目的人都知道,前端是离用户最近的一层,也是最容易被当成突破口的一层。用户输入的数据在这里落地,第三方资源在这里加载,浏览器各种存储机制在这里被调用,稍有不慎,一个看似不起眼的细节就能变成整个系统的软肋。

我做了十多年前端,从最早写jQuery插件到现在搞微前端架构,踩过的安全坑不算少。最典型的一次是某次大促前夜,运营同学在后台配置了一个带特殊字符的商品标题,结果整个列表页的DOM结构被撑爆,样式全乱,排查到凌晨三点才发现是拼接HTML时没做转义。这件事让我彻底意识到,前端安全不是“有空再优化”的选项,而是从第一行代码开始就要刻在脑子里的习惯。

这篇文章想聊的,就是把前端常见的安全问题一次性捋清楚。我会按攻击类型、触发原理、实际代码场景、防御手段这个顺序来讲,每个点都配上能直接抄的代码片段和配置示例。不管你是刚学完HTML CSS的新手,还是已经能独立带项目的资深开发,都能从中找到能立刻用上的东西。核心关键词就一个:前端安全。围绕它展开的XSS、CSRF、点击劫持、依赖链攻击、敏感信息泄露等问题,咱们一个一个拆。

2. XSS跨站脚本攻击:前端安全的第一大敌人

2.1 XSS到底是怎么发生的

XSS的全称是Cross-Site Scripting,翻译过来叫跨站脚本攻击。名字听着绕,原理其实特别简单:攻击者想办法把一段可执行的脚本代码塞进你的页面里,当其他用户访问这个页面时,这段代码就在他们的浏览器里跑起来了。因为浏览器分不清这段代码是你写的还是攻击者写的,所以它会老老实实执行。

举个最朴素的例子。假设你有个搜索页面,用户输入关键词后,页面显示“您搜索的是:xxx”。如果代码写成这样:

const keyword = new URLSearchParams(location.search).get('q'); document.getElementById('result').innerHTML = '您搜索的是:' + keyword;

正常用户输入“前端安全”,页面显示“您搜索的是:前端安全”,没问题。但如果攻击者构造了这样一个链接:

https://example.com/search?q=<img src=x onerror=alert(document.cookie)>

用户点开这个链接,innerHTML会把这段字符串当成HTML解析,<img>标签的onerror事件触发,alert(document.cookie)就执行了。攻击者可以把alert换成任何恶意代码,比如偷偷把cookie发到自己的服务器。

这就是最典型的反射型XSS。还有存储型XSS,恶意代码被存进数据库,所有访问该页面的用户都会中招,危害更大。以及DOM型XSS,完全在前端JS里发生,不经过服务器。

2.2 防御XSS的核心思路

防御XSS的核心原则只有一条:永远不要信任任何用户输入,永远不要直接把不可信数据插入到HTML里。

具体落地的时候,分几个层面来做。

第一层是输出编码。根据数据要插入的位置,选择对应的编码方式。插入HTML内容区,用HTML实体编码;插入HTML属性,用属性编码;插入JavaScript代码块,用JS编码;插入URL参数,用URL编码。很多同学只知道一个HTML转义,结果把数据放进onclick属性里就出问题了。

第二层是使用安全的API。能用textContent就不用innerHTML,能用setAttribute就不用字符串拼接。React、Vue这些框架默认会对插值做转义,但dangerouslySetInnerHTML和v-html是明确的逃生舱口,用的时候必须确保内容已经过严格净化。

第三层是CSP内容安全策略。这是一个HTTP响应头,告诉浏览器只允许加载和执行哪些来源的资源。配置得当的话,即使攻击者成功注入了脚本,浏览器也会拒绝执行。一个相对严格的CSP配置长这样:

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; form-action 'self'

这里script-src 'self'表示只允许执行同源脚本,内联脚本和eval都会被拦。object-src 'none'禁止加载Flash等插件内容。base-uri 'self'防止攻击者通过<base>标签劫持相对路径。

注意:CSP配置里尽量避免使用'unsafe-inline'和'unsafe-eval',这两个指令会大幅削弱防护效果。如果必须用内联脚本,可以用nonce或hash机制。

第四层是输入验证。虽然输入验证不能替代输出编码,但能在早期过滤掉明显恶意的内容。比如用户年龄字段只允许数字,那就用正则严格限制,别等到输出的时候再处理。

2.3 富文本场景下的XSS防护

实际项目里最头疼的是富文本编辑器场景。用户需要加粗、斜体、插入链接、上传图片,这些功能天然需要保留一部分HTML标签。这时候不能简单粗暴地全部转义,得用白名单机制做净化。

推荐使用成熟的净化库,比如DOMPurify。它的用法很简单:

import DOMPurify from 'dompurify'; const dirty = '<img src=x onerror=alert(1)><p>正常内容</p>'; const clean = DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['p', 'b', 'i', 'u', 'a', 'img', 'br'], ALLOWED_ATTR: ['href', 'src', 'alt', 'title'] }); document.getElementById('content').innerHTML = clean;

DOMPurify会解析HTML,移除不在白名单里的标签和属性,同时处理各种绕过技巧,比如<svg>里的脚本、<math>标签的变体等。自己写正则做净化几乎一定会被绕过,这不是危言耸听,是无数安全事件验证过的结论。

还有一个细节容易被忽略:href属性里的javascript:协议。<a href="javascript:alert(1)">点击</a>这种写法,点击就会执行脚本。DOMPurify默认会处理这个问题,但如果自己写过滤逻辑,一定要检查协议白名单,只允许http:、https:、mailto:、tel:这些安全协议。

3. CSRF跨站请求伪造:借用户之手干坏事

3.1 CSRF的攻击逻辑

CSRF的全称是Cross-Site Request Forgery,跨站请求伪造。它的思路和XSS不同,XSS是往页面里注入代码,CSRF是诱导用户在已登录状态下,去访问一个攻击者构造的页面,这个页面会自动向目标网站发起请求。

举个例子。假设某银行网站有个转账接口https://bank.com/transfer?to=xxx&amount=1000,用的是cookie鉴权。用户登录银行后,cookie就存在浏览器里。这时候用户点开了一个恶意页面,页面里藏了这样一段代码:

<img src="https://bank.com/transfer?to=attacker&amount=10000" style="display:none">

浏览器加载这个img时,会自动向银行网站发起GET请求,并且带上该网站的cookie。银行服务器一看cookie有效,就执行了转账操作。用户完全不知情。

这就是CSRF的可怕之处:它不需要窃取用户的cookie,只需要借用用户的登录状态。攻击者甚至不需要知道cookie的内容,浏览器会自动带上。

3.2 防御CSRF的几种武器

防御CSRF最有效的手段是CSRF Token。服务器在渲染表单时,生成一个随机token,存在session里,同时放在表单的隐藏字段中。提交时服务器比对token是否匹配。攻击者的页面无法获取这个token,因为同源策略限制了跨域读取。

<form action="/transfer" method="POST"> <input type="hidden" name="csrf_token" value="随机生成的token"> <input type="text" name="to"> <input type="number" name="amount"> <button type="submit">转账</button> </form>

服务器端验证逻辑:

app.post('/transfer', (req, res) => { const { csrf_token, to, amount } = req.body; if (csrf_token !== req.session.csrfToken) { return res.status(403).send('CSRF token验证失败'); } // 执行转账逻辑 });

第二种武器是SameSite Cookie属性。设置SameSite=Lax或SameSite=Strict后,浏览器在跨站请求时不会发送cookie。Lax模式允许顶级导航的GET请求携带cookie,Strict模式则完全禁止。对于大多数网站,Lax是平衡安全性和用户体验的选择。

Set-Cookie: sessionid=abc123; SameSite=Lax; Secure; HttpOnly

Secure表示只在HTTPS连接中发送cookie,HttpOnly表示JavaScript无法读取cookie,能有效缓解XSS窃取cookie的问题。

第三种武器是验证请求头。检查Origin和Referer头,确认请求来自合法来源。不过这两个头在某些情况下可能被省略或伪造,所以只能作为辅助手段。

实操心得:CSRF Token要绑定用户session,并且设置合理的过期时间。不要用固定token,也不要把token放在URL里,URL会被记录在浏览器历史、服务器日志、Referer头里,容易泄露。

3.3 前后端分离架构下的CSRF防护

现在很多项目是前后端分离的,前端用React或Vue,后端提供RESTful API。这种架构下,如果鉴权用的是JWT放在localStorage里,那CSRF的风险会小一些,因为攻击者的页面无法读取localStorage。但JWT放在cookie里的话,CSRF风险依然存在。

我的建议是:如果JWT存在cookie里,必须配合SameSite属性和CSRF Token双重防护。如果JWT存在localStorage里,要注意XSS的风险,因为XSS可以读取localStorage。两种方案各有取舍,没有银弹。

对于API请求,可以在请求头里加一个自定义字段,比如X-Requested-With: XMLHttpRequest,然后服务器检查这个头。跨域请求默认不能带自定义头,除非服务器明确允许。这也是一种轻量级的防护手段。

4. 点击劫持与界面伪装攻击

4.1 点击劫持的原理

点击劫持的英文是Clickjacking,也叫UI覆盖攻击。攻击者做一个透明的iframe,覆盖在一个诱导性的页面上。用户以为自己在点击“领取奖品”按钮,实际上点击的是iframe里某个网站的真实按钮,比如“确认转账”或“授权登录”。

这种攻击的防御手段主要是设置响应头。X-Frame-Options可以告诉浏览器是否允许页面被iframe嵌入:

X-Frame-Options: DENY

DENY表示完全禁止嵌入,SAMEORIGIN表示只允许同源页面嵌入。这个头兼容性很好,老浏览器也支持。

更现代的方案是CSP的frame-ancestors指令:

Content-Security-Policy: frame-ancestors 'self'

frame-ancestors比X-Frame-Options更灵活,可以指定多个允许的来源。如果两者同时存在,frame-ancestors优先级更高。

前端还可以用JavaScript做辅助防御,比如检测当前页面是否在iframe中:

if (window.self !== window.top) { window.top.location = window.self.location; }

但这种方式可以被绕过,只能作为补充,不能替代响应头。

4.2 界面伪装与视觉欺骗

除了iframe覆盖,还有一种攻击叫界面伪装。攻击者把恶意页面的外观做得和目标网站一模一样,诱导用户输入账号密码。这种攻击严格来说不算技术漏洞,更多是钓鱼手段,但前端可以做些事情来帮助用户识别。

比如在敏感操作页面显示用户自定义的标识,像“上次登录时间”、“欢迎语”等,让用户确认这是真实网站。银行网站常用这招。另外,全站强制HTTPS,配合HSTS头,能防止中间人篡改页面内容。

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

这个头告诉浏览器,在接下来的一年内,该域名的所有请求都必须用HTTPS。includeSubDomains覆盖所有子域名,preload表示可以申请加入浏览器预加载列表,连第一次HTTP请求都不会发出。

5. 第三方依赖与供应链安全

5.1 npm生态的依赖风险

现代前端项目动辄几百个npm包,直接依赖可能只有几十个,但传递依赖能到上千个。这些包来自全球各地的开发者,质量参差不齐。一个包被投毒,整个项目就沦陷了。

2018年有个著名事件,某个流行的npm包被作者注入恶意代码,会窃取用户的比特币钱包信息。这个包被下载了数百万次,影响范围极广。还有更隐蔽的,攻击者注册和知名包名字很像的包,比如cross-env和crossenv,开发者手滑打错字就中招了。

防御供应链攻击,首先要锁定依赖版本。package-lock.json和yarn.lock必须提交到版本库,确保每次安装的依赖树完全一致。其次要定期审计依赖:

npm audit npm audit fix

npm audit会检查依赖中的已知漏洞,并给出修复建议。对于无法自动修复的,要评估风险,必要时手动升级或替换。

还可以用npm outdated查看哪些包有新版本,定期更新。但更新前要在测试环境充分验证,避免引入破坏性变更。

5.2 第三方脚本的安全管理

很多项目会引入第三方脚本,比如统计工具、客服插件、广告SDK。这些脚本运行在页面里,拥有和自家代码一样的权限,能读取DOM、cookie、localStorage。如果第三方脚本被入侵,后果不堪设想。

管理第三方脚本,第一要限制来源,只从可信CDN加载。第二要用SRI(Subresource Integrity)校验文件完整性:

<script src="https://cdn.example.com/lib.js" integrity="sha384-xxxxxxxxxxxxxxxxxxxxxxxx" crossorigin="anonymous"></script>

integrity值是文件的哈希,浏览器加载后会校验,不匹配就拒绝执行。crossorigin="anonymous"表示跨域请求不带凭证。

第三,对于非关键的第三方脚本,可以异步加载,避免阻塞页面。还可以用iframe沙箱隔离,限制其访问父页面的能力。

注意:SRI只对静态文件有效,如果第三方脚本是动态生成的,SRI无法使用。这时候只能靠CSP限制来源,并定期审查第三方脚本的行为。

6. 敏感信息泄露与前端存储安全

6.1 前端存储的坑

前端能用的存储机制有好几种:cookie、localStorage、sessionStorage、IndexedDB。每种都有适用场景,但用错了就是安全隐患。

cookie会自动随请求发送,所以适合存session标识,但要设置HttpOnly、Secure、SameSite。localStorage容量大,适合存不敏感的用户偏好,但XSS能直接读取。sessionStorage和localStorage类似,但关闭标签页就清空。IndexedDB适合存大量结构化数据,同样受同源策略保护,但XSS也能访问。

绝对不要把敏感信息存在前端。API密钥、数据库密码、用户密码明文,这些都不应该出现在前端代码或存储里。有些同学图省事,把API密钥写在JS文件里,以为压缩混淆就安全了。实际上,打开开发者工具就能看到,混淆只是增加阅读难度,不是加密。

6.2 源码泄露与Source Map

生产环境的Source Map是个双刃剑。它能帮助定位线上错误,但也可能泄露源码结构和业务逻辑。如果Source Map文件被公开访问,攻击者能还原出接近原始的代码,发现潜在漏洞。

我的做法是:生产环境生成Source Map,但只上传到错误监控平台,不部署到CDN。或者用hidden-source-map模式,生成map文件但不添加引用注释,只有知道文件名的人才能访问。

// webpack.config.js module.exports = { devtool: 'hidden-source-map', // 其他配置 };

另外,要确保.env文件、配置文件、备份文件不会被部署到Web目录。Nginx配置里加上规则,禁止访问.开头的隐藏文件和常见备份后缀:

location ~ /\. { deny all; } location ~* \.(env|log|bak|sql|zip|tar\.gz)$ { deny all; }

6.3 错误信息与日志泄露

开发环境的详细错误堆栈对调试很有帮助,但生产环境必须关闭。一个未捕获的异常可能把数据库表名、文件路径、框架版本都暴露出来。攻击者收集这些信息后,能更有针对性地发起攻击。

前端要做的,是统一错误处理,给用户展示友好的提示,同时把详细错误上报到监控平台。不要直接把error.stack渲染到页面上。

window.addEventListener('error', (event) => { // 上报到监控平台 reportError({ message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack }); // 给用户展示友好提示 showFriendlyMessage('页面出了点小问题,请稍后重试'); // 阻止默认的控制台输出 return true; });

7. 常见问题与排查技巧实录

7.1 安全自查清单

项目上线前,我习惯过一遍下面这个清单。每项都确认过,心里才踏实。

检查项检查内容风险等级
XSS防护所有用户输入是否经过编码或净化高
CSP配置是否设置了合理的Content-Security-Policy高
CSRF防护敏感操作是否有Token或SameSite保护高
点击劫持是否设置了X-Frame-Options或frame-ancestors中
依赖安全是否运行过npm audit且无高危漏洞高
敏感信息前端代码和存储中是否包含密钥或密码高
HTTPS是否全站HTTPS并配置HSTS高
Source Map生产环境是否隐藏或限制访问中
错误处理生产环境是否关闭详细错误输出中
第三方脚本是否使用SRI且限制来源中

7.2 排查XSS的实用技巧

遇到疑似XSS的问题,我一般按这个顺序排查。先看数据流:用户输入从哪来,经过哪些处理,最终插入到哪里。重点检查innerHTML、outerHTML、document.write、eval、setTimeout字符串参数这些危险API。

然后看编码:插入HTML内容区用HTML实体编码,插入属性用属性编码,插入JS用JS编码。很多漏洞是因为编码方式用错了,比如把用户输入直接拼进onclick属性里,HTML实体编码在属性里会被解码,等于没编码。

最后看CSP:打开开发者工具,看Console里有没有CSP违规报告。如果有,说明有脚本被拦截了,可能是攻击尝试,也可能是自己的代码需要调整。

7.3 依赖漏洞的应急处理

npm audit报出高危漏洞时,先别慌。第一步看漏洞详情,确认影响范围。有些漏洞只在特定条件下触发,如果你的项目没用那个功能,风险就低。第二步看有没有修复版本,有就升级。第三步如果暂时无法升级,看能否通过配置或代码调整规避。

我遇到过一种情况:某个依赖的漏洞需要升级大版本才能修复,但大版本有破坏性变更,短期升不了。这时候的临时方案是用overrides字段强制指定子依赖的版本:

{ "overrides": { "vulnerable-package": "^2.0.1" } }

这个字段在npm 8.3以上支持,能强制所有依赖使用指定版本。但用之前要测试,确保不会引入兼容性问题。

7.4 安全与体验的平衡

做安全防护,最怕的是为了安全把用户体验搞崩。CSP配得太严,内联脚本全挂;SameSite设成Strict,从外部链接跳转过来登录状态就丢了;CSRF Token过期时间太短,用户填个长表单提交就失败。

我的经验是:安全策略要分层,核心操作严格,非核心操作宽松。比如支付、改密码、授权登录这些,必须强验证;浏览、搜索、看公开内容,可以适当放宽。CSP可以用Report-Only模式先跑一段时间,收集违规报告,确认不影响正常功能后再强制启用。

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

这个头不会拦截任何内容,只上报违规行为。观察一两周,把误报处理完,再切换到强制模式。

8. 把安全变成开发习惯

写了这么多,其实核心就一句话:前端安全不是某个阶段的任务,而是贯穿开发全流程的习惯。写组件的时候多想一步“这个数据来自用户,我转义了吗”,引入依赖的时候多看一眼“这个包靠谱吗”,配置服务器的时候多检查一遍“响应头设对了吗”。

我自己的做法是在项目里建一个SECURITY.md,把上面那些检查项和配置示例都记进去。每次新项目启动,直接复制过来,按清单过一遍。新同学入职,也让他们先读这个文档,知道哪些线不能踩。

还有一个习惯是定期做安全演练。找个测试环境,自己尝试构造XSS payload、CSRF请求、点击劫持页面,看看防护措施是否生效。这种实战比看文档印象深得多。

最后分享一个我常用的XSS测试payload集合,放在测试环境里验证防护效果:

<img src=x onerror=alert(1)> <svg onload=alert(1)> <a href="javascript:alert(1)">click</a> <iframe src="javascript:alert(1)"> <div style="background:url(javascript:alert(1))"> <input onfocus=alert(1) autofocus> <details open ontoggle=alert(1)>

这些payload在测试环境跑一遍,如果都被拦截或转义了,说明防护基本到位。如果有一个弹窗,就顺着数据流往回查,找到漏洞点修复。

安全这件事,做得好没人夸,出了事就是大事。与其事后救火,不如平时多花点时间把基础打牢。上面这些内容,都是我这些年踩坑踩出来的经验,希望能帮你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进