跳转到帖子

Html5 本地存储安全实战:localStorage/sessionStorage 中敏感数据泄露的定位与修复

精选回复

发布于

背景

在 Html5 应用里,localStorage 和 sessionStorage 用起来很方便,很多前端项目会把登录态、用户配置、钱包地址、临时签名结果等数据直接塞进去。问题是,这类存储不具备访问隔离能力:同源页面里的任意 JavaScript 都能读取。一旦页面存在 XSS、第三方脚本被污染,或者浏览器插件注入脚本,存储里的数据会直接暴露。

这篇只讨论 Html5 Web Storage 的实际排查和修复,不展开泛泛的 Web 安全概念。重点场景是:前端页面使用 localStorage/sessionStorage 保存 token、签名参数、用户身份信息,导致被脚本读取或长期残留。

典型问题场景

线上经常能看到类似代码:

localStorage.setItem('token', loginResult.token);
localStorage.setItem('userId', loginResult.userId);
localStorage.setItem('walletAddress', account.address);
localStorage.setItem('signMessage', message);

const token = localStorage.getItem('token');
fetch('/api/user/profile', {
  headers: {
    Authorization: 'Bearer ' + token
  }
});

这类实现的问题不在于 Html5 API 本身,而是把不该长期暴露给前端脚本的数据,放进了所有同源脚本都能读取的位置。尤其是 localStorage 没有过期时间,用户关闭浏览器后仍会保留。

问题定位

排查可以从浏览器和代码两条线同时做。

1. 浏览器侧检查

打开 Chrome DevTools,进入:

  • Application
  • Storage
  • Local Storage / Session Storage
  • 选择当前站点 origin

重点检查以下字段:

  • access_token、refresh_token、jwt、session、sid
  • privateKey、mnemonic、seed、secret
  • signature、signMessage、nonce
  • userId、phone、email、realName 等身份信息

如果这些值在 localStorage 里长期存在,就需要进入修复流程。sessionStorage 虽然浏览器标签关闭后会清空,但同一标签页内仍然能被任意同源脚本读取,也不能用于保存高敏数据。

2. 代码侧搜索

在项目根目录执行:

grep -R "localStorage\|sessionStorage" ./src ./public ./pages ./components

grep -R "setItem" ./src | grep -E "token|jwt|secret|key|signature|wallet|mnemonic|seed"

如果是 Windows 环境,可以用 ripgrep:

rg "localStorage|sessionStorage" src public pages components
rg "setItem" src | rg "token|jwt|secret|key|signature|wallet|mnemonic|seed"

定位时不要只看业务代码,也要看第三方登录、埋点、钱包连接、缓存工具封装层。例如不少项目会封装一个 storage.ts,实际所有敏感字段都从这里写入。

技术分析

Html5 Web Storage 的几个边界需要明确:

  • localStorage 按 origin 隔离,不按路径隔离。同一个协议、域名、端口下的页面都能访问。
  • localStorage 没有内置过期机制,除非业务主动清理。
  • sessionStorage 按标签页隔离,但同源脚本仍可读写。
  • Web Storage 只能被 JavaScript 访问,不能设置 HttpOnly。
  • 一旦页面存在 XSS,攻击脚本可以直接调用 getItem 读取数据。

因此,访问令牌、刷新令牌、助记词、私钥、长期签名材料不应放在 localStorage 或 sessionStorage。短期 UI 状态、主题配置、列表筛选条件、非敏感缓存可以放。

可执行修复方案

方案一:认证态改用 HttpOnly Cookie

如果是传统 Web 登录,推荐让服务端下发 HttpOnly、Secure、SameSite Cookie,前端不再保存 access token。

Set-Cookie: sid=SESSION_ID_VALUE; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800

前端请求时带上 credentials:

fetch('/api/user/profile', {
  method: 'GET',
  credentials: 'include'
});

对应后端需要做 CSRF 防护,不能因为用了 Cookie 就忽略请求来源校验。常见做法是 SameSite=Lax 配合 CSRF Token,涉及转账、改密、绑定钱包等敏感操作时要额外验证。

方案二:前端只保存短期非敏感状态

例如主题、语言、分页大小可以继续用 localStorage:

localStorage.setItem('theme', 'dark');
localStorage.setItem('locale', 'zh-CN');
localStorage.setItem('pageSize', '20');

但以下字段应禁止写入:

const forbiddenKeys = [
  'token', 'access_token', 'refresh_token', 'jwt',
  'privateKey', 'mnemonic', 'seed', 'secret',
  'signature', 'signMessage'
];

方案三:增加开发期拦截,防止误写敏感字段

可以在开发环境加入一个简单的 Web Storage 包装层,所有业务代码禁止直接调用 localStorage.setItem。

const SENSITIVE_KEY_PATTERN = /(token|jwt|secret|privateKey|mnemonic|seed|signature|refresh|access)/i;

export function safeSetStorage(key, value, options = {}) {
  const storage = options.session ? sessionStorage : localStorage;

  if (SENSITIVE_KEY_PATTERN.test(key)) {
    throw new Error(`禁止将敏感字段写入 Web Storage: ${key}`);
  }

  storage.setItem(key, JSON.stringify({
    value,
    createdAt: Date.now()
  }));
}

export function safeGetStorage(key, maxAgeMs) {
  const raw = localStorage.getItem(key);
  if (!raw) return null;

  try {
    const data = JSON.parse(raw);
    if (maxAgeMs && Date.now() - data.createdAt > maxAgeMs) {
      localStorage.removeItem(key);
      return null;
    }
    return data.value;
  } catch (e) {
    localStorage.removeItem(key);
    return null;
  }
}

同时建议通过 ESLint 限制直接使用 localStorage/sessionStorage:

{
  "rules": {
    "no-restricted-globals": [
      "error",
      {
        "name": "localStorage",
        "message": "请使用 storage 封装层,禁止直接操作 localStorage"
      },
      {
        "name": "sessionStorage",
        "message": "请使用 storage 封装层,禁止直接操作 sessionStorage"
      }
    ]
  }
}

方案四:页面卸载和登出时主动清理

如果历史版本已经写入过敏感数据,修复时不要只改新代码,还要清理旧数据。可以在应用启动时执行一次迁移清理:

const LEGACY_SENSITIVE_KEYS = [
  'token',
  'access_token',
  'refresh_token',
  'jwt',
  'privateKey',
  'mnemonic',
  'seed',
  'signature',
  'signMessage'
];

export function purgeLegacySensitiveStorage() {
  for (const key of LEGACY_SENSITIVE_KEYS) {
    localStorage.removeItem(key);
    sessionStorage.removeItem(key);
  }
}

登出时也要清理业务缓存,避免换账号后串数据:

function logout() {
  fetch('/api/logout', {
    method: 'POST',
    credentials: 'include'
  }).finally(() => {
    sessionStorage.clear();

    for (const key of Object.keys(localStorage)) {
      if (key.startsWith('app_cache_') || key.startsWith('user_')) {
        localStorage.removeItem(key);
      }
    }

    location.href = '/login';
  });
}

风险验证方法

修复前后可以用以下方式验证。先在控制台模拟恶意同源脚本读取:

Object.keys(localStorage).forEach((key) => {
  console.log('[localStorage]', key, localStorage.getItem(key));
});

Object.keys(sessionStorage).forEach((key) => {
  console.log('[sessionStorage]', key, sessionStorage.getItem(key));
});

如果仍能看到 token、私钥、签名原文、refresh token,说明修复不完整。

再检查构建产物,避免旧逻辑残留:

rg "localStorage\.setItem|sessionStorage\.setItem" dist build .next

很多项目源码改了,但旧的工具库或打包产物里仍然存在 setItem 写敏感字段,这类问题在灰度发布时比较常见。

边界条件

  • Web Storage 不适合保存任何可以直接代表身份或资产控制权的数据。
  • 对钱包类页面,不要把私钥、助记词、未加密 keystore 放入 localStorage。
  • 如果必须保存钱包相关状态,只保存地址、链 ID、连接状态这类非敏感信息。
  • 加密后再放 localStorage 也不是万能方案。如果密钥也在前端环境里,遇到 XSS 仍可能被一起拿走。
  • sessionStorage 不是安全容器,只是生命周期更短。

建议的字段分级

数据类型是否适合 Web Storage建议处理方式
主题、语言、分页设置适合localStorage,可加过期时间
购物车草稿、表单草稿谨慎避免包含手机号、身份证、地址等敏感字段
access token不建议优先 HttpOnly Cookie 或内存态短期保存
refresh token禁止服务端安全 Cookie 或服务端会话
私钥、助记词、seed禁止不要进入浏览器持久存储
钱包地址、链 ID可接受仅作为展示状态,不作为认证依据

总结

Html5 的 localStorage 和 sessionStorage 适合做轻量状态缓存,不适合保存认证凭据和高敏数据。实际整改时,建议按三步走:先全局搜索定位写入点,再把认证态迁移到 HttpOnly Cookie 或内存态,最后增加封装层和规则,防止后续代码再次误写。

这类问题修起来不复杂,但很容易漏在历史字段、第三方模块和打包产物里。上线前用 DevTools、源码搜索、构建产物搜索各查一遍,基本能把风险压下来。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

这个点在实战里建议不要只盯着 localStorage,可以按“定位来源 → 判断敏感性 → 修复数据流”的方式查,效率会高一些。

1. 先定位哪些 key 在写入敏感数据

浏览器控制台可以直接扫一遍当前域下的本地存储:

Object.keys(localStorage).forEach(k => {
  console.log('[localStorage]', k, localStorage.getItem(k))
})

Object.keys(sessionStorage).forEach(k => {
  console.log('[sessionStorage]', k, sessionStorage.getItem(k))
})

如果怀疑 token、手机号、身份证、邮箱、订单号等信息泄露,可以做简单关键字过滤:

const patterns = [/token/i, /jwt/i, /auth/i, /phone/i, /mobile/i, /idcard/i, /email/i, /user/i]

for (const store of [localStorage, sessionStorage]) {
  Object.keys(store).forEach(k => {
    const v = store.getItem(k) || ''
    if (patterns.some(p => p.test(k) || p.test(v))) {
      console.warn(k, v)
    }
  })
}

更关键的是找到是谁写进去的。可以在 DevTools 里给 Storage.prototype.setItem 下 hook,刷新页面后看调用栈:

const rawSetItem = Storage.prototype.setItem

Storage.prototype.setItem = function (key, value) {
  console.trace('[setItem]', this === localStorage ? 'localStorage' : 'sessionStorage', key, value)
  return rawSetItem.apply(this, arguments)
}

这样一般能直接定位到前端某个登录逻辑、用户信息缓存逻辑,或者第三方 SDK。

2. 判断风险时别只看“是否加密”

很多项目会说 localStorage 里的内容做了 Base64 或 AES 加密,但如果密钥也在前端 JS 里,实际防护价值很有限。前端能解,攻击者拿到 JS 也能解。localStorage/sessionStorage 的核心问题是:一旦页面存在 XSS,里面的数据基本都能被读走。

所以像下面这些不建议放:

  • JWT、access_token、refresh_token
  • 用户手机号、邮箱、身份证号
  • 后台权限信息、角色列表、菜单权限
  • 支付、订单、地址等业务敏感信息

3. 修复建议:token 走 HttpOnly Cookie

如果是登录态,优先把 token 从 localStorage/sessionStorage 移到服务端设置的 Cookie,至少加上这些属性:

Set-Cookie: sid=xxxxx; HttpOnly; Secure; SameSite=Lax; Path=/

如果是跨站点场景需要携带 Cookie,再考虑:

Set-Cookie: sid=xxxxx; HttpOnly; Secure; SameSite=None; Path=/

前端请求要带上凭证:

fetch('/api/userinfo', {
  method: 'GET',
  credentials: 'include'
})

后端 CORS 不能用通配符:

Access-Control-Allow-Origin: 
Access-Control-Allow-Credentials: true

4. 如果确实需要前端缓存,建议只缓存非敏感展示数据

比如主题、语言、最近访问 tab 这类可以放。用户信息建议只缓存必要的非敏感字段,并设置版本和过期时间,不要长期裸存:

function setCache(key, value, ttlMs) {
  localStorage.setItem(key, JSON.stringify({
    value,
    expireAt: Date.now() + ttlMs
  }))
}

function getCache(key) {
  const raw = localStorage.getItem(key)
  if (!raw) return null

  try {
    const data = JSON.parse(raw)
    if (Date.now() > data.expireAt) {
      localStorage.removeItem(key)
      return null
    }
    return data.value
  } catch {
    localStorage.removeItem(key)
    return null
  }
}

但这个只是减少暴露窗口,不是安全边界。

5. 上线前可以加一个简单检查项

在测试环境跑完主要业务流程后,打开 DevTools 检查 Application → Local Storage / Session Storage。也可以在自动化测试里加断言,避免后续又把 token 写回去:

const forbiddenKeys = [/token/i, /jwt/i, /auth/i, /secret/i, /password/i]

for (const k of Object.keys(localStorage)) {
  if (forbiddenKeys.some(p => p.test(k))) {
    throw new Error(`敏感 key 不应出现在 localStorage: ${k}`)
  }
}

最后补一点:修这个问题最好和 XSS 防护一起看。即便迁移到 HttpOnly Cookie,也要同步检查输出编码、CSP、依赖库版本、富文本过滤,否则只是降低 token 被直接读取的概率,不能替代前端安全治理。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。