前端安全从 XSS 和 CSP 开始:别把用户输入当成可信内容
前端安全最容易被忽略的原因,是很多问题在开发环境里看起来“不像 bug”。
页面能渲染,接口能请求,用户也能正常操作。但只要某个用户输入被当成 HTML 插进页面,或者第三方脚本被随便加载,风险就已经埋下了。等到真的出现 XSS,影响通常不是一个弹窗那么简单,而是登录态、用户数据、后台权限都可能被拿走。
一个很典型的例子是运营后台的富文本公告:后台可以编辑内容,前台直接展示。图省事的第一版实现往往是 dangerouslySetInnerHTML 加一个只在编辑器层面限制标签的黑名单。这套组合看起来能挡住常规输入,但只要有人往内容里塞一个带事件属性的图片标签:
1<img src="x" onerror="alert(document.cookie)">
浏览器解析到这个 onerror 时会照常执行里面的脚本,alert 只是最温和的演示,真正的攻击者会换成读取 document.cookie、劫持表单提交或者发起后台管理请求。问题的根源不在编辑器限制得够不够严,而在于把“来自后台的数据”默认当成了可信内容——只要内容有一个非开发者直接写死的来源,无论它经过了几层系统流转,对前端渲染层来说都要按外部输入对待。
XSS 的本质是把不可信内容当成了可信内容
XSS 不是只发生在评论区。任何用户或外部系统可控的内容,只要进入页面渲染链路,都可能成为入口。评论、昵称、签名、地址是最常被想到的一批,但真正容易漏的是那些“看起来不像用户输入”的来源:CMS 里的富文本、URL 参数、第三方接口返回、Markdown 渲染出来的结果、文件名和图片描述、批量导入的数据,甚至后端接口返回的 message。它们的共同点是数据不是开发者亲手写死的——只要来源不可控,无论中间经过了多少层系统流转,到了前端渲染层就得按外部输入对待。
React 默认会对文本插值做转义:
1function Profile({ name }) { 2 return <div>{name}</div> 3}
即使 name 是 <script>alert(1)</script>,也只会当成文本显示。这是 React 帮我们兜住的基础安全线。
真正危险的是绕过转义:
1<div dangerouslySetInnerHTML={{ __html: content }} />
dangerouslySetInnerHTML 不是不能用,而是必须把它当成一个安全边界。进入这里的 HTML 要么来自完全可信的静态内容,要么经过严格清洗。
富文本要清洗,不要靠黑名单
富文本最容易踩坑。
很多人会想:我把 <script> 标签过滤掉不就行了?不够。XSS 的入口太多了,比如事件属性、javascript: URL、SVG、iframe、style 里的表达式、嵌套实体编码。
不要自己写正则清洗 HTML。正则看起来快,实际很难覆盖住所有变体写法。
更稳妥的做法是使用成熟的 sanitizer,并采用白名单策略:只允许业务确实需要的标签和属性。
1import DOMPurify from 'dompurify' 2 3const cleanHtml = DOMPurify.sanitize(html, { 4 ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre'], 5 ALLOWED_ATTR: ['href', 'target', 'rel'], 6})
链接还要额外注意。允许 href 不代表允许所有协议,javascript:、data: 都可能有风险。外链最好补上:
1rel="noopener noreferrer"
否则新页面可以通过 window.opener 反过来把原页面导航到钓鱼站,这类攻击叫 tabnabbing。现代浏览器给 target="_blank" 默认加上了 noopener,但你没法保证所有用户都是新版本浏览器,也没法保证 sanitizer 放行的 <a> 一定带上,所以清洗时把 rel 补齐仍然是稳妥的做法。
如果是 Markdown,也不要以为“不是 HTML 就安全”。很多 Markdown 渲染器允许内嵌 HTML,代码高亮插件也可能生成 HTML。渲染 Markdown 的链路里同样需要明确:是否允许 HTML,允许哪些标签,最终结果是否清洗。我们的做法是 Markdown 转出 HTML 之后,再统一过一遍 DOMPurify,而不是信任渲染器自己的“安全模式”——那个模式各家实现不一,你根本摸不清它到底放行了哪些标签。
还有一类容易被 sanitizer 漏掉的是 DOM XSS:内容没经过 innerHTML,而是被塞进了某个属性或 API 里。比如把用户输入拼进 href、src,或者直接 location.href = userInput——一个 javascript: 开头的字符串就能执行脚本,DOMPurify 清洗 HTML 结构那一步根本管不到它。所以凡是用户可控的值要进跳转地址、window.open、eval、new Function、setAttribute('on...') 这些地方,都得单独校验协议、白名单,别指望富文本清洗替你兜住。
顺带说一个 2024 年值得关注、但我还没敢全量铺开的方向:Trusted Types。它的思路是从浏览器层面强制——凡是往 innerHTML、script.src 这类“危险汇聚点”写字符串,必须先经过一个你注册的、可信的转换策略,否则浏览器直接拒绝:
1Content-Security-Policy: require-trusted-types-for 'script';
1const policy = trustedTypes.createPolicy('app-html', { 2 createHTML: (input) => DOMPurify.sanitize(input), 3}) 4 5el.innerHTML = policy.createHTML(userHtml)
它相当于把“所有 HTML 写入都必须过清洗”这条纪律从人肉 review 变成浏览器强制执行,漏一处就报错。目前主要是 Chromium 系支持,Safari、Firefox 还没跟上,所以我现在只在内部管理系统里试,对外的站点先当作一个方向盯着,等兼容性再宽一些。
不要把接口错误原样塞进 HTML
有一种 XSS 很隐蔽:错误信息。
比如后端返回:
1{ 2 "message": "<img src=x onerror=alert(1)>" 3}
前端如果直接用文本渲染,问题不大:
1<div>{error.message}</div>
但如果为了高亮或者换行,把错误信息拼成 HTML:
1toastHtml(`<strong>提交失败:</strong>${error.message}`)
风险就来了。
我的习惯是:接口返回的 message 只当普通文本,不进入 HTML 拼接。需要富文本提示时,模板由前端写死,变量只走文本插值。
这类隐蔽入口的共性是:危险不在“显示文本”这一步,而在“把不可信内容拼成了 HTML”这一步。只要 error.message 始终走文本插值、永远不进 innerHTML 或模板字符串拼接,它就翻不起浪来。一旦为了加粗、换行、变色去拼 HTML,就等于在错误提示这条不起眼的链路上开了个注入口。
API 错误也要有兜底处理。不要把未知错误、堆栈、SQL 信息直接展示给用户:
1function getSafeErrorMessage(error) { 2 if (error.code === 'VALIDATION_ERROR') return '请检查表单填写内容' 3 if (error.code === 'UNAUTHORIZED') return '登录已过期,请重新登录' 4 if (error.code === 'FORBIDDEN') return '没有权限执行该操作' 5 return '操作失败,请稍后重试' 6}
这不是为了隐藏问题,而是前端展示和内部诊断要分开。详细错误可以进日志系统,用户界面只展示可行动、可理解的安全文案。
把内部错误细节暴露给用户,本身也是一类安全问题。一条带表名的 SQL 报错、一段带文件路径的堆栈,对攻击者都是免费的侦察信息——他借此摸清你用的数据库、目录结构、框架版本。所以这层过滤是双重收益:用户看到的是能行动的提示,攻击者拿不到用来进一步攻击的线索。开发环境可以打全量日志方便自己排查,生产环境返回给前端的那份必须是收敛过的。
Token 存储不是只有 localStorage 这一个选项
聊 XSS 绕不开 token。
很多单页应用会把 access token 存在 localStorage。这很方便,刷新页面也不丢。但一旦发生 XSS,攻击脚本可以直接读走:
1localStorage.getItem('token')
这不是说 localStorage 永远不能用,而是要理解风险。如果业务权限高、数据敏感,就应该优先考虑 HttpOnly Cookie,让 JavaScript 根本读不到 token。
sessionStorage、内存变量这些方案也常被提起,各有取舍:sessionStorage 关标签页就清,比 localStorage 生命周期短,但一样能被同源脚本读;把 token 只放在内存里(比如一个模块级变量)最难被顺走,但刷新即丢,得配合刷新流程重新拿。没有一个方案能同时做到“方便、持久、又绝对读不到”,关键是想清楚你在防的是什么威胁、能接受什么代价。
Cookie 方案也不是银弹,它得配全一套属性才有意义:HttpOnly 禁止 JS 读取(这才是防 XSS 偷 token 的关键),Secure 保证只在 HTTPS 下发送,SameSite=Lax 或 Strict 降低 CSRF 风险,Path/Domain 把作用范围收到最小。任何一个漏配,防护就有缺口——只设 HttpOnly 不设 Secure,token 就可能在明文连接里被截走。
用 Cookie 鉴权还得处理 CSRF。SameSite 能挡掉一大部分跨站请求,但遇到跨站表单提交、老浏览器、某些特殊跳转,仍然可能得补 CSRF token 或双重提交 Cookie 兜底。
到底选哪种,我按业务风险分档来判断。普通低风险的前台页面,短期 access token 配刷新机制是可以接受的,图的是省事;一旦是管理后台、资金、隐私数据这类,就应该优先 HttpOnly Cookie,让脚本根本碰不到 token。但无论哪种方案,有一条不能省——XSS 防护本身。
因为 XSS 一旦成立,攻击者即使读不到 token,也能直接在当前页面上下文里发起操作:你有的权限,注入进来的脚本就有。它以你的身份调后台接口,一样能改数据。HttpOnly 只是降低了 token 被搬走、拿到别处复用的风险,并不能让已经跑在你页面里的恶意脚本失效。所以 token 存储的取舍是在“万一被攻破,损失能不能收窄”这一层,前面那道防注入的线,才是根本。
CSP 是第二道防线
CSP(Content Security Policy)不是用来替代转义和清洗的,它是第二道防线。
一个基础 CSP 可能长这样:
1Content-Security-Policy: 2 default-src 'self'; 3 script-src 'self'; 4 style-src 'self' 'unsafe-inline'; 5 img-src 'self' data: https:; 6 connect-src 'self' https://api.example.com; 7 frame-ancestors 'none'; 8 base-uri 'self'; 9 object-src 'none';
它表达的是:脚本只能从本站加载,图片允许本站、data 和 https,接口只能连本站和指定 API,页面不允许被 iframe 嵌入。
这里几条各有分工,别只盯着 script-src。frame-ancestors 'none' 管的是点击劫持——不让别的站点把你的页面套进 iframe,诱导用户在不知情的情况下点你的按钮,它比老的 X-Frame-Options 头更灵活。base-uri 'self' 防的是攻击者注入 <base> 标签改写页面里所有相对链接的基准地址。object-src 'none' 干脆禁掉 <object>、<embed> 这类老古董插件入口。这些指令平时没存在感,但每一条都堵着一类具体攻击,配 CSP 时值得逐条过一遍,而不是复制一段 default-src 'self' 了事。
真正上线 CSP 时不要一口气卡死。老项目里通常有很多内联脚本、第三方统计、客服组件、地图组件。直接强制上线,很可能把业务打挂。
更稳的方式是先用 Report-Only:
1Content-Security-Policy-Report-Only: 2 default-src 'self'; 3 script-src 'self'; 4 report-uri /api/csp-report;
浏览器不会拦截,只会把违反策略的行为上报到 report-uri 指向的接口。观察一段时间后,你会拿到一份真实的“哪些资源正在越界”的清单——通常里面一半是你早忘了的历史内联脚本,另一半是接进来的第三方域名。据此逐档收紧,再切到正式 CSP,才不会一上线就把业务打挂。
顺带一提,report-uri 是老指令,新标准里换成了 report-to 配合 Reporting-Endpoints 头,但到 2024 年两者并存、report-uri 兼容性仍更广,稳妥起见我一般两个都写,让新老浏览器都能把违规送回来。
如果必须使用内联脚本,优先考虑 nonce,而不是长期放开 'unsafe-inline':
1script-src 'self' 'nonce-random-value'
页面里只有带相同 nonce 的脚本能执行:
1<script nonce="random-value"> 2 window.__APP_CONFIG__ = {} 3</script>
nonce 必须每次响应随机生成,不能写死。写死就失去意义了——攻击者拿到那个固定值,注入的脚本照样带上它就能执行。
内联脚本还有个连带问题:nonce 只对页面里直接写死的那个 <script> 生效,如果这个脚本又动态插入了别的脚本,那些新脚本没有 nonce,照样被 CSP 拦掉。第三方 SDK 常常这么干,一上 CSP 就白屏。这时候可以配合 strict-dynamic——它的意思是“信任带 nonce 的脚本,以及由它信任地加载出来的后续脚本”,把信任沿加载链传递下去:
1script-src 'nonce-random-value' 'strict-dynamic';
但 strict-dynamic 也意味着 'self'、白名单域名这些会被忽略,等于把信任完全押在 nonce 链上,收益是策略更简洁、更难绕过,代价是接入前得把加载链摸清楚。我的经验是老项目别一步到位,先在 Report-Only 里挂着,看真实上报的违规都是些什么,再决定收到多紧。CSP 的落地从来不是写一条完美策略贴上去,而是先观察、再逐档收紧的过程。
第三方脚本要当成供应链风险
前端页面常常会接很多第三方脚本:统计、客服、广告、A/B 实验、监控、地图。它们运行在你的页面上下文里,和你自己的代码同样的权限——能读 DOM、能读 Cookie(非 HttpOnly 的那部分)、能监听表单。等于你把一部分安全信任交给了这些外部域名。
所以接入前我会挨个盘问:这个脚本是否必须同步加载,能不能延迟到用户同意后再加载;有没有明确的域名白名单;是否支持 Subresource Integrity;它会不会读取表单里的内容;出了问题能不能远程关掉。这几个问题里但凡有几个答不上来,就得掂量掂量值不值得接。尤其是“能否远程关闭”——第三方脚本出问题时,你能不能不发版就把它摘掉,往往决定了故障是十分钟还是一整天。
能加 SRI 的静态资源尽量加:
1<script 2 src="https://cdn.example.com/sdk.js" 3 integrity="sha384-..." 4 crossorigin="anonymous" 5></script>
SRI 的原理是浏览器下载完资源后算一遍 hash,跟 integrity 里写的对不上就拒绝执行。这样即便 CDN 被攻破、脚本内容被悄悄换掉,篡改过的版本也跑不起来。但它只适合版本固定、内容不变的资源。如果第三方脚本每次返回内容都在变(很多统计、A/B SDK 就是这样),hash 对不上反而会把正常脚本挡掉,这时候 SRI 用不上,得靠 CSP 白名单、域名控制和远程开关来管。
还有个局限要知道:SRI 只校验你在 HTML 里直接写 integrity 的那个脚本,它动态插入的后续脚本管不到。所以对那种“加载器脚本再拉一堆子脚本”的 SDK,SRI 只能锁住第一跳,后面的链路仍要靠 CSP 的域名限制去收。安全从来不是单点,是几层机制叠着用。
第三方脚本还有性能风险。安全和性能经常是同一个问题的两面:你让一个外部脚本在首屏同步执行,它既可能拖慢页面,也可能扩大攻击面。
我的前端安全检查清单
做前端安全不需要一开始就追求完美体系,先把高风险入口守住。
我会优先检查这些点:
- 所有用户输入是否默认按文本渲染
dangerouslySetInnerHTML是否有清洗来源和白名单- Markdown / 富文本是否禁用或清洗 HTML
- URL 参数是否会进入 DOM、跳转地址或脚本配置
- 接口错误是否按文本展示,并有兜底文案
- token 是否暴露给 JavaScript,风险是否可接受
- Cookie 是否设置 HttpOnly、Secure、SameSite
- 是否有基础 CSP,并计划从 Report-Only 渐进收紧
- 第三方脚本是否有白名单、延迟加载和关闭开关
前端安全不需要记住多少攻击 payload,只要养成一个习惯:拿到一个数据是不是自己写死的,不是就当外部输入处理。回到开头那条运营后台公告——真要把这套习惯落下去,dangerouslySetInnerHTML、拼接 URL、eval、new Function、直接操作 innerHTML 这几个关键字出现在 review 里,都值得多问一句数据来源;CI 里也可以加一道 lint 规则(比如 eslint-plugin-security 或者针对 dangerouslySetInnerHTML 的自定义规则),在这些高风险 API 被引入时强制加注释说明清洗方式,而不是全靠人肉记住。定期跑一次 npm audit 或者 Snyk 这类依赖扫描也值得纳入常规流程,前端供应链里第三方包被投毒、传递依赖里混进恶意脚本的案例并不少见,这类风险和业务代码里的 XSS 同样需要覆盖到。
那个 <img onerror> 事后看很好懂,但它当时能混进公告内容,就是因为编辑器的标签黑名单从没被当成“不可信输入”认真对待过。这条线守住了,很多问题在代码 review 阶段就能提前暴露,不必等到安全扫描才发现。