跳到主内容

前端敏感信息防泄露三道防线实战 ​

问题背景:前端没有真正的“私密代码” ​

很多团队把密钥、内部接口、灰度开关、员工标识直接写在前端代码里,默认认为压缩后的产物不可读。事实上,浏览器 DevTools 的 Sources 面板、Source Map 文件、静态资源搜索、错误监控堆栈都可能还原这些信息。尤其是以下三类内容风险最高:

  • sourceMap 被发布到公网,业务源码、注释、路径完整还原;
  • 日志、错误对象、表单值包含手机号、token、身份证等敏感数据;
  • 未混淆的变量名、接口名、管理端标识让攻击者更容易理解系统结构。

前端安全不能只依赖 HTTPS 或登录态。本文给出一套工程化治理方案:sourceMap 治理 + 脱敏 + 混淆,三道防线分别解决“源码可见、数据可读、逻辑易懂”的问题。

防线一:sourceMap 分级治理与上传监控 ​

sourceMap 泄露链路 ​

sourceMap 的价值是帮助线上排查问题,但如果 .map 文件和静态资源一起部署到 CDN,任何人都可能通过 //# sourceMappingURL=app.js.map 找到源码。更隐蔽的问题是,部分框架默认只隐藏入口 map,异步 chunk 仍然生成 map。

本地构建生成 hidden mapCI 上传监控平台或内网制品库公网部署删除 map 与引用线上报错服务端还原堆栈

Vite 生产环境配置 ​

推荐生产构建使用 hidden:生成 map 供 CI 上传,但不在 JS 产物末尾写 sourceMappingURL。构建完成后立即上传并从发布目录移除。

typescript
// vite.config.ts
import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  const isProd = mode === 'production'

  return {
    plugins: [vue()],
    build: {
      // 开发环境正常生成;生产环境生成但不写引用
      sourcemap: isProd ? 'hidden' : true,
      rollupOptions: {
        output: {
          // 拆分稳定依赖,避免单个 chunk 过大
          manualChunks: {
            vue: ['vue', 'vue-router', 'pinia']
          }
        }
      }
    }
  }
})

CI 中可以增加一个上传脚本。示例用环境变量表示监控平台地址,真实项目替换为 Sentry、ARMS 或内部平台 SDK。

javascript
// scripts/upload-sourcemap.mjs
import { readdirSync, statSync, unlinkSync, rmSync } from 'node:fs'\nimport { join, extname } from 'node:path'\n
const distDir = 'dist'
const uploadUrl = process.env.SOURCEMAP_UPLOAD_URL
const release = process.env.COMMIT_SHA

if (!uploadUrl || !release) {
  throw new Error('缺少 SOURCEMAP_UPLOAD_URL 或 COMMIT_SHA')
}

async function walk(dir) {
  const files = []
  for (const name of readdirSync(dir)) {
    const path = join(dir, name)
    if (statSync(path).isDirectory()) files.push(...await walk(path))
    else if (extname(name) === '.map') files.push(path)
  }
  return files
}

const maps = await walk(distDir)
for (const file of maps) {
  const body = new FormData()
  body.append('release', release)
  body.append('file', new Blob([await import('node:fs/promises').then(fs => fs.readFile(file))]))
  const res = await fetch(uploadUrl, { method: 'POST', body })
  if (!res.ok) throw new Error(`sourceMap 上传失败:${file} ${res.status}`)
  unlinkSync(file) // 上传成功后立即删除,避免进入制品发布阶段
}

// 兜底清理空目录,防止误把 assets 下的 map 带到 CDN
rmSync(join(distDir, '.map-cache'), { recursive: true, force: true })
console.log(`已上传并清理 ${maps.length} 个 sourceMap 文件`)

package 脚本建议固定顺序:先构建,再上传,最后才部署。不要把“部署后清理 map”当作方案,因为制品一旦进入公共 CDN,边缘缓存和第三方爬虫可能已经保存。

json
{
  "scripts": {
    "build": "vite build",
    "upload:map": "node scripts/upload-sourcemap.mjs",
    "release": "npm run build && npm run upload:map && npm run publish:dist"
  }
}

防线二:统一脱敏,避免敏感数据进入日志与错误上报 ​

只治理 sourceMap 还不够。接口响应、异常对象、Vue 事件、URL query、localStorage 都可能携带敏感信息。脱敏的关键不是在每个页面手写判断,而是建立统一的数据出口:请求日志、错误上报、埋点、调试面板都走同一个 sanitizer。

可复用脱敏函数 ​

typescript
// src/utils/sanitize.ts
type Primitive = string | number | boolean | null | undefined

const SECRET_KEYS = [
  'password', 'passwd', 'token', 'accesstoken', 'refreshToken',
  'secret', 'authorization', 'idcard', 'phone', 'mobile', 'email'
]

function isSecretKey(key: string) {
  const lower = key.toLowerCase().replace(/[_-]/g, '')
  return SECRET_KEYS.some(item => lower.includes(item.toLowerCase()))
}

export function maskText(value: string) {
  // 手机号:保留前 3 后 2
  if (/^1[3-9]\d{9}$/.test(value)) return value.replace(/^(\d{3})\d{6}(\d{2})$/, '$1******$2')
  // 邮箱:保留首字符和域名
  if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) {
    const [name, domain] = value.split('@')
    return `${name[0]}***@${domain}`
  }
  return value.length > 8 ? `${value.slice(0, 3)}***${value.slice(-2)}` : '***'
}

export function sanitize<T>(input: T, depth = 0): T | Primitive | object {
  if (depth > 6 || input === null || input === undefined) return input
  if (typeof input === 'string') return maskText(input)
  if (typeof input === 'number' || typeof input === 'boolean') return input
  if (Array.isArray(input)) return input.map(item => sanitize(item, depth + 1))
  if (typeof input === 'object') {
    return Object.fromEntries(
      Object.entries(input).map(([key, value]) => {
        if (isSecretKey(key)) return [key, '***']
        if (typeof value === 'string') return [key, maskText(value)]
        return [key, sanitize(value, depth + 1)]
      })
    )
  }
  return input
}

在请求与错误监控中统一接入 ​

typescript
// src/utils/logger.ts
import { sanitize } from './sanitize'

const enabled = import.meta.env.MODE !== 'production'

export const logger = {
  info(message: string, payload?: unknown) {
    const safePayload = payload ? sanitize(payload) : undefined
    if (enabled) console.info(message, safePayload)
    // 上报到内部日志系统时也必须传脱敏后的数据
    navigator.sendBeacon('/api/client-log', JSON.stringify({
      level: 'info',
      message,
      payload: safePayload,
      time: Date.now()
    }))
  },
  error(error: unknown, extra?: unknown) {
    const safeError = error instanceof Error
      ? { name: error.name, message: sanitize(error.message), stack: error.stack }
      : sanitize(error)
    navigator.sendBeacon('/api/client-error', JSON.stringify({
      error: safeError,
      extra: sanitize(extra),
      href: location.pathname // 不要上报完整 search,避免 URL 中带 ticket/token
    }))
  }
}

Vue3 的全局错误钩子也应接入,而不是直接把 errorInfo 原样上报。

typescript
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { logger } from './utils/logger'

const app = createApp(App)

app.config.errorHandler = (error, instance, info) => {
  logger.error(error, {
    info,
    component: instance?.$options.__name ?? 'AnonymousComponent'
  })
}

app.mount('#app')

表单与本地存储注意点 ​

不要为了“自动登录”把长期有效 token 放到 localStorage;更不要把完整身份证、银行卡写入持久化缓存。若业务必须缓存用户信息,缓存前先脱敏或只存最小字段。

typescript
function saveUserProfile(profile: { name: string; phone: string; userId: string }) {
  localStorage.setItem('user_profile', JSON.stringify({
    userId: profile.userId,
    name: profile.name,
    phone: profile.phone.replace(/^(\d{3})\d{6}(\d{2})$/, '$1******$2')
  }))
}

防线三:构建期代码混淆,提高逆向成本 ​

压缩和混淆不是同一回事。minify 主要缩短变量和删除空格,而混淆会进一步处理字符串、属性名、控制流和调试行为。前端混淆会增加包体积和运行时开销,因此不建议全量最高强度混淆,应优先处理管理后台、支付、风控、加密参数生成等敏感模块。

使用 javascript-obfuscator 处理指定 chunk ​

javascript
// scripts/obfuscate-chunks.mjs
import { obfuscate } from 'javascript-obfuscator'
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'

const assetsDir = 'dist/assets'
// 只混淆包含敏感业务命名的 chunk,避免影响 vue 等基础依赖
const targets = readdirSync(assetsDir).filter(name =>
  name.endsWith('.js') && /(admin|payment|risk|auth)/.test(name)
)

for (const name of targets) {
  const file = join(assetsDir, name)
  const code = readFileSync(file, 'utf8')
  const result = obfuscate.obfuscate(code, {
    compact: true,
    controlFlowFlattening: true,
    controlFlowFlatteningThreshold: 0.4,
    deadCodeInjection: true,
    deadCodeInjectionThreshold: 0.2,
    identifierNamesGenerator: 'hexadecimal',
    stringArray: true,
    stringArrayThreshold: 0.6,
    splitStrings: true,
    splitStringsChunkLength: 12,
    disableConsoleOutput: true,
    selfDefending: false // 开启后可能影响兼容性与错误定位,需要充分回归
  })
  writeFileSync(file, result.getObfuscatedCode())
}

console.log(`已混淆 ${targets.length} 个敏感 chunk`)

再把脚本放到 sourceMap 上传之后执行,避免混淆后的代码和原始 map 版本不匹配。

json
{\n  "scripts": {
    "obfuscate": "node scripts/obfuscate-chunks.mjs",
    "release": "npm run build && npm run upload:map && npm run obfuscate && npm run publish:dist"
  }
}

如果使用 Vite 插件,也可以在 vite.config.ts 中通过 rollup-plugin-javascript-obfuscator 的 include 限定范围。属性名混淆尤其要谨慎,因为 Vue props、接口字段、localStorage key、后端 JSON 字段依赖原名称,盲目开启会导致页面白屏或请求字段错乱。

三道防线的选型边界 ​

方案解决问题推荐强度主要代价
sourceMap 治理防止源码、注释、目录结构直接还原所有生产环境必须启用需要 CI 上传与权限管理
数据脱敏防止日志、监控、缓存泄露真实敏感值所有数据出口强制接入需要维护字段规则与测试
代码混淆提高业务逻辑逆向分析成本仅敏感模块按需启用包体积、性能、调试成本上升

三者不能互相替代。没有 sourceMap 治理,混淆只是增加阅读时间;没有脱敏,攻击者即使看不懂代码,也能从网络请求和错误日志拿到数据;没有混淆,内部管理端路径、风控规则和加密流程更容易被梳理。

避坑总结:至少检查这 5 个高频错误 ​

  1. 错误现象:生产 JS 末尾仍能看到 sourceMappingURL。 原因通常是把 sourcemap: false 理解为“不影响上传”,或 CI 只删除了入口 map,异步 chunk 没删。修正方法是生产使用 hidden,构建后递归扫描 dist/**/*.map,上传成功再删除,并在发布流水线增加 find dist -name '*.map' 检查。

  2. 错误现象:监控平台能定位源码,但公网也能下载 map。 这是把 map 上传到了公开 CDN 同目录。正确做法是 map 只进内网制品库或监控平台,访问需要项目权限和版本号;公网静态服务器还应配置拒绝 .map、.ts、.vue、sourcemap 相关路径。

  3. 错误现象:手机号在接口层已脱敏,错误日志里仍出现完整号码。 常见原因是异常对象中包含请求配置 config.data、响应 response.data 或表单 model,日志函数只处理了普通 message。修正方法是对 Error、AxiosError、URLSearchParams、FormData、普通对象分别做递归 sanitize,并禁止上报 location.search。

  4. 错误现象:开启属性名混淆后页面白屏或接口字段变成短名。 这是因为混淆工具修改了对象属性,破坏了 Vue props、JSON 字段和 DOM dataset。修正方法是默认只混淆局部变量和字符串,不开启激进的 renameProperties;确需开启时用白名单保留接口字段,并通过端到端回归验证。

  5. 错误现象:测试环境一切正常,上线后包体积暴涨、低端机卡顿。 通常是全量开启控制流扁平化、死代码注入和字符串拆分。混淆不是强度越高越好,应只针对 admin/payment/risk/auth 等 chunk,设置阈值,并用 Lighthouse、性能埋点和 Source Map 还原后的错误率做发布门禁。

最后要强调:前端代码运行在用户设备上,任何硬编码密钥都无法靠混淆真正保密。真正的密钥必须放在服务端,前端只持有短期、最小权限、可吊销的访问凭证。把 sourceMap、脱敏和混淆做到位,可以显著降低信息暴露和逆向风险,但安全边界仍然来自服务端鉴权、权限校验与数据最小化。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。