外观
前端敏感信息防泄露三道防线实战
问题背景:前端没有真正的“私密代码”
很多团队把密钥、内部接口、灰度开关、员工标识直接写在前端代码里,默认认为压缩后的产物不可读。事实上,浏览器 DevTools 的 Sources 面板、Source Map 文件、静态资源搜索、错误监控堆栈都可能还原这些信息。尤其是以下三类内容风险最高:
- sourceMap 被发布到公网,业务源码、注释、路径完整还原;
- 日志、错误对象、表单值包含手机号、token、身份证等敏感数据;
- 未混淆的变量名、接口名、管理端标识让攻击者更容易理解系统结构。
前端安全不能只依赖 HTTPS 或登录态。本文给出一套工程化治理方案:sourceMap 治理 + 脱敏 + 混淆,三道防线分别解决“源码可见、数据可读、逻辑易懂”的问题。
防线一:sourceMap 分级治理与上传监控
sourceMap 泄露链路
sourceMap 的价值是帮助线上排查问题,但如果 .map 文件和静态资源一起部署到 CDN,任何人都可能通过 //# sourceMappingURL=app.js.map 找到源码。更隐蔽的问题是,部分框架默认只隐藏入口 map,异步 chunk 仍然生成 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 个高频错误
错误现象:生产 JS 末尾仍能看到
sourceMappingURL。 原因通常是把sourcemap: false理解为“不影响上传”,或 CI 只删除了入口 map,异步 chunk 没删。修正方法是生产使用hidden,构建后递归扫描dist/**/*.map,上传成功再删除,并在发布流水线增加find dist -name '*.map'检查。错误现象:监控平台能定位源码,但公网也能下载 map。 这是把 map 上传到了公开 CDN 同目录。正确做法是 map 只进内网制品库或监控平台,访问需要项目权限和版本号;公网静态服务器还应配置拒绝
.map、.ts、.vue、sourcemap 相关路径。错误现象:手机号在接口层已脱敏,错误日志里仍出现完整号码。 常见原因是异常对象中包含请求配置
config.data、响应response.data或表单 model,日志函数只处理了普通 message。修正方法是对Error、AxiosError、URLSearchParams、FormData、普通对象分别做递归 sanitize,并禁止上报location.search。错误现象:开启属性名混淆后页面白屏或接口字段变成短名。 这是因为混淆工具修改了对象属性,破坏了 Vue props、JSON 字段和 DOM dataset。修正方法是默认只混淆局部变量和字符串,不开启激进的
renameProperties;确需开启时用白名单保留接口字段,并通过端到端回归验证。错误现象:测试环境一切正常,上线后包体积暴涨、低端机卡顿。 通常是全量开启控制流扁平化、死代码注入和字符串拆分。混淆不是强度越高越好,应只针对
admin/payment/risk/auth等 chunk,设置阈值,并用 Lighthouse、性能埋点和 Source Map 还原后的错误率做发布门禁。
最后要强调:前端代码运行在用户设备上,任何硬编码密钥都无法靠混淆真正保密。真正的密钥必须放在服务端,前端只持有短期、最小权限、可吊销的访问凭证。把 sourceMap、脱敏和混淆做到位,可以显著降低信息暴露和逆向风险,但安全边界仍然来自服务端鉴权、权限校验与数据最小化。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。