前端安全基线:日常项目先守住这些底线

判断一个前端项目安全不安全,我不会先看它上没上 WAF、有没有渗透报告,而是先问几个更朴素的问题:用户输进来的文本,是当纯文本渲染还是拼进了 HTML;登录态是放在 localStorage 还是 HttpOnly Cookie;跳转参数信不信得过;上传接口后端有没有再校一遍。这几个问题的答案,基本能判断出一个日常业务系统的安全下限在哪。

做电商中后台第六年,我见过的安全问题绝大多数都不来自什么精巧的攻击链,而是这些最基本的防线没守住:一个未过滤的富文本、一个照单全收的 redirect、一个躺在 lockfile 里几个月没人管的过期依赖。这些东西不需要攻击者多高的水平,普通用户随手一试都可能触发。

所以我对"安全基线"的理解不是一开始就做到极致,而是项目里不能踩明显的底线。下面这几条,是我给自己和团队定的最低要求,判断标准写在每一节里。

底线一:用户输入默认当文本,不当 HTML

第一条最简单也最容易出事:任何来自用户、URL、接口、第三方系统的数据,默认都不能直接当 HTML 塞进页面。

判断很直接——如果你只是把它显示成文字,React 的文本插值默认会转义,这条路是安全的:

1<div>{username}</div>

危险的是主动打开这道门:

1<div dangerouslySetInnerHTML={{ __html: content }} />

一旦用了 dangerouslySetInnerHTML,你就是在告诉框架"这段字符串我信得过,当 HTML 渲染"。问题是 content 到底信不信得过,得由你来担保。如果它来自用户填写的富文本,就必须先经过白名单清洗。

这里我踩过一个认知误区:早期想自己写正则把 <script> 过滤掉就完事,结果发现 XSS 的入口远不止 script 标签,onerroronload 这类事件属性、javascript: 协议的 href<img src=x onerror=...> 都能触发。手写正则永远漏,正确做法是上成熟的清洗库,走白名单——只保留允许的标签和属性,其余一律剥掉。

Markdown 是同一类问题的变体。很多 Markdown 渲染器允许内嵌原生 HTML,等于给了 XSS 一条侧门。要么在渲染器配置里禁用 HTML,要么渲染成 HTML 之后再走一遍清洗。判断标准就一句:这段内容最终会不会变成 DOM 结构,只要会,就得清洗。

清洗这件事我不自己造轮子,直接上成熟库。DOMPurify 是这类场景的常用选择,默认配置就已经能挡掉绝大多数已知向量,需要收紧时再显式配白名单:

1import DOMPurify from 'dompurify'
2
3const clean = DOMPurify.sanitize(dirtyHtml, {
4  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li', 'br'],
5  ALLOWED_ATTR: ['href', 'title'],
6})

这里有个判断点常被忽略:白名单放行了 <a href>,但 href 本身还能塞 javascript: 伪协议。DOMPurify 默认会拦这类危险协议,可如果你自己拼链接、或者用了会保留 href 的宽松配置,就得额外校验协议只允许 httphttpsmailto。清洗不是"上个库就完事",放行了哪些标签属性、这些属性的值还能不能带毒,都得想到。

还有个 SVG 的坑值得单说。用户上传或粘贴的 SVG 里可以内嵌 <script> 和事件属性,如果你把 SVG 当富文本直接 innerHTML 进页面,等于执行了一段用户脚本。DOMPurify 对 SVG 有专门的处理模式,但更稳的做法是——除非业务真的要内联 SVG,否则用户来源的 SVG 一律当图片资源用 <img src> 引,而不是内联进 DOM。

底线二:接口返回的错误,也算不可信输入

有个容易被忽略的角度是:不可信的不只是用户直接输入的东西,后端返回的错误信息同样可能带毒。

1{ "message": "<img src=x onerror=alert(1)>" }

如果你只把 message 当文本显示,问题不大;可一旦图省事把它拼进一段提示 HTML,风险就回来了。更常见的情况是,后端把某个用户可控的字段原样回显在报错里,攻击面就顺着这条路穿了过来。

我的处理方式是错误信息不透传,前端按错误码映射成自己的文案:

1function getErrorMessage(error) {
2  if (error.code === 'VALIDATION_ERROR') return '请检查填写内容'
3  if (error.code === 'UNAUTHORIZED') return '登录已过期'
4  return '操作失败,请稍后重试'
5}

后端的详细错误、堆栈、SQL 片段不该出现在用户界面上——它们既可能带不可信内容,也会泄露系统内部结构,给攻击者递情报。这些东西应该进日志系统,而不是弹窗。

这里的判断标准是分层:给用户看的是脱敏后的友好文案,给排查用的详细信息进日志、带上请求 ID,出问题时拿 ID 去日志里捞完整上下文。前端还要顺手做一件事——生产环境别把 console.log 里的敏感对象、接口原始响应留在控制台,构建时把调试输出剥掉,免得把内部数据结构白送到用户面前。

底线三:token 存哪,按风险选,不图方便

登录态存哪里,是个典型的"什么情况用 A、什么情况用 B"的判断题。

把 token 存 localStorage 确实方便,取用都是同步的、跨标签页也好共享。但它的代价是:一旦页面发生 XSS,攻击脚本能直接 localStorage.getItem 把 token 读走,等于把钥匙放在了脚本随手能摸到的地方。

对高风险场景——后台管理、资金、隐私数据,我更倾向用 HttpOnly Cookie:

1HttpOnly
2Secure
3SameSite=Lax 或 Strict

HttpOnly 让 JS 读不到这个 Cookie,Secure 保证只走 HTTPS,SameSite 限制跨站携带。这里要说清一个常见误解:HttpOnly 不等于 XSS 免疫。就算脚本读不到 Cookie,它仍然在当前页面上下文里,照样能带着 Cookie 发请求。所以 HttpOnly 是给 token 加了道锁,但 XSS 防护永远是第一位的——门锁得再好,人进了屋也没用。

选了 Cookie 鉴权,就要连带评估 CSRF。SameSite=Lax 能挡住大部分跨站表单提交场景,但如果业务里有复杂的跨站交互,可能还得配一套 CSRF token。这里的判断标准是:数据越敏感、越怕被盗用登录态,越应该往 HttpOnly Cookie 加 CSRF 防护的方向走,别为了取用方便一律塞 localStorage。

CSRF token 前端这侧的实操也简单:登录后拿到一个 token,放在自己能读到的地方(比如一个非 HttpOnly 的 Cookie 或页面里的 meta),每次写操作请求都带上它:

1axios.interceptors.request.use((config) => {
2  const method = (config.method || '').toUpperCase()
3  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
4    config.headers['X-CSRF-Token'] = getCsrfToken()
5  }
6  return config
7})

这里的判断是:CSRF token 只需要挂在会改变数据的请求上,GET 这类幂等读取不用带。这套所谓的"双重提交"之所以有效,是因为跨站攻击者能诱导浏览器带上 Cookie,却读不到、也伪造不出这个 token。SameSite 和 CSRF token 不是二选一,敏感后台我倾向两道一起上。

底线四:跳转参数不能照单全收

开放跳转是那种"看着人畜无害、实际能被拿来钓鱼"的经典漏洞:

1/login?redirect=https://evil.example.com

如果登录成功后直接跳 redirect 参数,攻击者就能构造一个指向你站点的登录链接,用户在你这儿登完录,转头被甩到一个仿冒页面上。因为跳转发生在用户信任的域名之后,迷惑性很强。

这里的判断标准是:redirect 默认只允许站内路径,任何带协议、带域名的绝对地址一律拒绝。

1function safeRedirect(redirect) {
2  if (!redirect || !redirect.startsWith('/')) {
3    return '/'
4  }
5
6  if (redirect.startsWith('//')) {
7    return '/'
8  }
9
10  return redirect
11}

这里 // 那一行别漏。//evil.example.com 是协议相对 URL,浏览器会当成跳到外站,但它确实以 / 开头,只判断 startsWith('/') 会被绕过。如果业务确实需要跳外部地址,那就维护一份域名白名单,命中才放行,绝不相信原样传进来的 URL 参数。

底线五:上传前端只做体验,后端才是防线

文件上传这一条最容易被误判,因为前端能做的校验看着挺全:

1if (!file.type.startsWith('image/')) {
2  throw new Error('只能上传图片')
3}
4
5if (file.size > 5 * 1024 * 1024) {
6  throw new Error('文件不能超过 5MB')
7}

但要清醒:这两段只是体验优化,不是安全防线file.type 是浏览器根据扩展名猜的,能伪造;请求也能绕过前端直接打接口。真正的文件类型、大小、内容合法性、病毒扫描、存储权限,全都必须后端再校一遍。前端拦一道是为了让正常用户少等一次失败往返,仅此而已。

上传之后的访问同样是个隐患点。用户传的 HTML、SVG、PDF 都可能内嵌脚本或钓鱼内容,如果直接放在主站同域下用浏览器打开,SVG 里的脚本就在你的域上执行了。稳妥做法是把用户上传的文件放到独立域名,访问时带上 Content-Disposition: attachment 之类的下载响应头,让浏览器倾向下载而不是内联渲染。

文件名本身也算不可信输入。用户可以把文件命名成 ../../etc/passwd 这类带路径穿越的名字,如果后端拿它直接拼存储路径就有风险;前端在展示文件名时也别忘了转义,一个叫 <img onerror=...>.png 的文件名照样能触发 XSS。处理上传,得把类型、大小、内容、文件名、访问方式当成五件独立的事分别校验,漏哪件都可能出问题。

底线六:依赖要持续看,别只盯运行时代码

前端依赖动辄上百个,漏洞是常态而非意外。这一条不像前面几条有明确的对错,它更像一项需要持续做的例行动作:锁死 lockfile、定期跑一次依赖审计、盯着构建插件和脚手架这类间接依赖、不随手装没人维护的小包、把用不上的依赖清掉。

有个视角容易被忽略:风险不只在跑进浏览器的运行时代码里。构建时依赖同样危险,因为它们在 install 和构建阶段就能执行脚本,一个被投毒的构建工具可以在你机器上或 CI 里为所欲为,而这段代码可能根本不会出现在最终产物中。所以审计范围要覆盖到 devDependencies。

引入一个新依赖前,我会先过一遍这几个判断:是不是真的需要、维护活不活跃、会不会进客户端包、有没有更轻的替代、会不会经手敏感数据。判断的落点是——每多一个依赖,就多一份需要你负责的攻击面,能不加就不加。

底线七:第三方脚本别裸引,加上 SRI

页面里那些从 CDN 引的第三方脚本,是另一处容易被忽略的供应链风险。你信任的是脚本此刻的内容,但 CDN 上的文件是可能被替换的——一旦源站被攻破,所有引它的页面都会跟着执行被篡改的代码。

应对的手段是 SRI(子资源完整性)。给 <script> 加上内容哈希,浏览器加载后会比对,对不上就拒绝执行:

1<script
2  src="https://cdn.example.com/lib.js"
3  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
4  crossorigin="anonymous"
5></script>

判断标准是:只要脚本来源不在你自己的完全控制之下,就该带 SRI。它换来的代价是每次第三方更新版本你得同步更新哈希,但比起脚本被悄悄换掉却毫无察觉,这点维护成本可以接受。能自己托管的关键脚本,我更倾向直接放到自己的域名下,从根上少一层信任转移。

底线八:密钥别进前端包

有一类事故不是被攻击出来的,是自己泄出去的:把不该出现在客户端的东西打进了前端包。前端代码是完全公开的,用户按一下 F12 就能看全,所以任何写进前端构建产物的字符串,都要默认它已经公开。

最常见的踩法是把服务端密钥、第三方服务的私有 key 直接写进代码或 .env,然后被打包工具塞进了 bundle。判断标准很硬:只有确定可以公开的配置,才能进前端。像地图、支付这类第三方服务,通常会区分公开 key 和私有 key,前端只能用带域名白名单限制的公开 key,真正的私有 key 必须留在后端。

Vite 这类工具在这里其实给了一道提示——只有以 VITE_ 前缀命名的环境变量才会被注入客户端,没这个前缀的默认不会暴露。这个约定别绕过:

1// 会被打进前端包,必须是可公开的
2const publicMapKey = import.meta.env.VITE_MAP_KEY
3
4// 没有 VITE_ 前缀,不会进客户端,用于本地脚本或服务端
5// SECRET_TOKEN=xxx

上线前扫一遍构建产物里有没有出现不该出现的关键字,或者在 CI 里加一道密钥扫描,比出事后再轮换密钥省心得多。

底线九:基础响应头,能配就配上

有一层防线成本很低却常被漏掉:安全响应头。

1Content-Security-Policy
2X-Content-Type-Options: nosniff
3Referrer-Policy: strict-origin-when-cross-origin
4Permissions-Policy
5Content-Security-Policy: frame-ancestors

X-Content-Type-Options: nosniff 阻止浏览器擅自嗅探 MIME 类型,避免把一个伪装成图片的脚本当脚本跑;Referrer-Policy 控制跳转时泄露多少来源信息,跳到外站时不把带敏感参数的完整 URL 一并带过去;Permissions-Policy 关掉页面用不到的摄像头、麦克风等能力,缩小可被滥用的接口面。这些头单个看都不起眼,胜在成本极低、配一次长期生效,属于那种"没理由不配"的基础防线。

其中最有价值也最容易误伤业务的是 CSP。它能极大压缩 XSS 的可利用空间,但配严了会把正常的内联脚本、第三方资源全拦下来。判断怎么落地:老项目别一上来就强制,先用 Content-Security-Policy-Report-Only 跑一段时间,把违规上报收集起来,摸清楚页面到底加载了哪些东西,再逐步收紧到强制模式。

CSP 里最难处理的是内联脚本。很多老页面到处是内联 <script> 和内联事件,最省事的写法是加 'unsafe-inline',但这几乎等于把 CSP 对 XSS 的防护废掉一大半。更稳的方向是用 nonce——服务端每次响应生成一个随机值,只有带这个 nonce 的内联脚本才被放行:

1Content-Security-Policy: script-src 'self' 'nonce-r4nd0m123'
1<script nonce="r4nd0m123"> /* 只有这段被允许执行 */ </script>

代价是 nonce 必须每次请求都变、且不能被静态缓存,需要服务端配合。判断标准是:新项目值得一开始就走 nonce 或 hash 的严格 CSP,老项目则先 Report-Only 摸底,再决定改造深度,别在没搞清页面依赖前就把自己锁死。

CSP 还有个常被忽略的收益:它能顺带堵住数据外泄的通道。攻击脚本就算注入进来,connect-srcimg-src 这些指令限制了它能往哪些域发请求、加载资源,等于给"偷到数据往哪送"这一步也上了锁。所以严格 CSP 不只是防脚本执行,也是在防执行之后的破坏,这是它比单点防御值钱的地方。

防止页面被恶意站点用 iframe 套壳做点击劫持,用 CSP 的 frame-ancestors 比老的 X-Frame-Options 表达力更强:

1Content-Security-Policy: frame-ancestors 'none'

需要允许特定业务方内嵌,就把 'none' 换成具体来源。

底线十:前端的权限判断只是体验

最后一条,是把前面所有条目串起来的那个原则:前端做的一切权限控制,都只是体验优化,不是安全防线。

后台系统里到处是按角色显示/隐藏按钮、按权限过滤菜单的代码。这些当然要做,否则用户会看到一堆点了就报错的入口。但要清醒:这些判断全在客户端,全都能被绕过。用户可以改前端状态、可以直接构造请求打接口,前端藏起来的按钮不代表对应的接口就调不到。

1// 这只是让界面更合理,不是安全控制
2if (user.role === 'admin') {
3  showDeleteButton()
4}

真正的授权必须在后端,每个敏感接口都要独立校验调用者有没有权限,不能假设"能调到这个接口的人前端一定给他开了入口"。这条判断和文件上传那条是一个道理——前端负责让正常用户用得顺,后端负责让越界的人过不去。把这两件事的职责分清楚,安全的地基才算稳。

这套底线怎么用

把上面几条串起来,日常项目上线前我会对着过一遍:用户输入是不是默认当文本、富文本和 Markdown 有没有走 DOMPurify 清洗、用户来源的 SVG 有没有当图片处理、接口错误有没有做安全脱敏、token 存储配不配得上数据风险等级、Cookie 三件套有没有齐、CSRF 写请求带没带 token、redirect 有没有限站内、上传后端有没有再校一遍、依赖审计跑没跑、第三方脚本带没带 SRI、密钥有没有误进前端包、敏感接口后端有没有独立鉴权、基础响应头漏没漏。

这份清单不是让你机械打勾,每一条背后都是同一个判断逻辑:输入默认不可信、权限一定在后端、错误不对外泄露、依赖要克制、每一条规则都要落到具体的判断标准上

清单会随着项目长而变,但这几条判断逻辑是稳定的。新接一个项目,我不会先去翻它有没有安全工具、有没有扫描报告,而是拿这几条逻辑当尺子量一遍:看它信不信任用户输入、把登录态放哪、越权靠不靠前端拦、密钥有没有裸露。量下来心里就有数了——大多数风险不在缺什么高级防护,而在这几条朴素的底线里漏了哪一条。

这些底线能不能日复一日守住,比买哪一个安全产品、做几次渗透测试更能决定一个系统的安全水位。先把这几条守住,一个日常业务系统的风险面就已经小了一大截。