前端安全从 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 里。比如把用户输入拼进 hrefsrc,或者直接 location.href = userInput——一个 javascript: 开头的字符串就能执行脚本,DOMPurify 清洗 HTML 结构那一步根本管不到它。所以凡是用户可控的值要进跳转地址、window.openevalnew FunctionsetAttribute('on...') 这些地方,都得单独校验协议、白名单,别指望富文本清洗替你兜住。

顺带说一个 2024 年值得关注、但我还没敢全量铺开的方向:Trusted Types。它的思路是从浏览器层面强制——凡是往 innerHTMLscript.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=LaxStrict 降低 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-srcframe-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、evalnew Function、直接操作 innerHTML 这几个关键字出现在 review 里,都值得多问一句数据来源;CI 里也可以加一道 lint 规则(比如 eslint-plugin-security 或者针对 dangerouslySetInnerHTML 的自定义规则),在这些高风险 API 被引入时强制加注释说明清洗方式,而不是全靠人肉记住。定期跑一次 npm audit 或者 Snyk 这类依赖扫描也值得纳入常规流程,前端供应链里第三方包被投毒、传递依赖里混进恶意脚本的案例并不少见,这类风险和业务代码里的 XSS 同样需要覆盖到。

那个 <img onerror> 事后看很好懂,但它当时能混进公告内容,就是因为编辑器的标签黑名单从没被当成“不可信输入”认真对待过。这条线守住了,很多问题在代码 review 阶段就能提前暴露,不必等到安全扫描才发现。