尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

前端打包产物敏感凭证泄露:路径、排查与封堵实战

前端打包产物敏感凭证泄露:路径、排查与封堵实战 1. 先接受一个现实浏览器里没有真正的隐藏文件1.1 打包产物最终落在用户手里就没有秘密可言做前端这几年我见过太多次线上 JS 文件里被人翻出阿里云 AccessKey的场面。每次出这种事第一反应都是查打包配置、查环境变量、查 CI 流程最后发现根子往往不在工具而在一个很多人不愿意面对的事实你构建出的 JavaScript 打包文件最终会被每一个访问你网站的访客完整下载到本地。不管你是用 Webpack、Vite 还是 Rollup产物就是一堆可以被任何人打开、格式化、搜索的文本。浏览器里的隐藏从来都是伪命题。这个问题之所以反复出现是因为很多团队的认知还停留在我把密钥放在 .env 文件里服务器上才有用户应该看不到。这句话前半句对后半句完全错。只要构建过程中有任何一步把密钥以字符串的形式写进了产物那它就等于贴在了公网上。网上搜JavaScript 打包文件中为何仍频现敏感凭证泄露能看到各种案例有的是历史项目遗留有的是复制粘贴时顺手把测试 key 带上了生产有的是把 SDK 的专属密钥当成普通配置项填进了前端代码。形式五花八门但暴露路径大同小异。这篇文章不会只讲你要小心我会把凭证进入打包产物的每一条路径、构建工具的真实行为、以及我能复现的排查和封堵手段完整拆开。如果你是刚学 JavaScript 不久或者长期只写业务代码没关注过产物内容这篇文章能帮你建立一套先怀疑、再排查、后修复的基本流程。1.2 泄露的凭证通常长什么样先说清楚我们要防的东西是什么。前端产物里最常见的泄露凭证无非这么几类凭证类型典型特征泄露后果云厂商 AccessKeyAKIA 开头的 AWS 访问密钥、阿里云 AccessKey ID/Secret直接控制存储桶、扣费、拉走数据AI 服务 API Keysk- 开头的 OpenAI、以及其他大模型服务的密钥被盗刷、被刷额度、产生天价账单Google 系服务 KeyAIza 开头的 API Key常见于地图、翻译等前端直连被绕开限制调用产生费用支付/风控密钥Stripe sk_live、支付宝、微信支付私钥等资金安全直接受威胁JWT/私有 TokeneyJ... 开头的 JSON Web Token伪造身份、越权操作数据库连接串mongodb:// 或 postgres:// 开头的完整地址服务器直接裸奔这些凭证的共同点是有明确的前缀、可被正则表达式快速命中而且一旦出现在打包产物里基本等于是公开的。前两年有个很出名的案例某大模型应用的前端 bundle 里带了一个团队共用的 sk- 密钥上线不到一天就被爬虫扫出来刷了几千美元。这种事故和攻击手法高明不高明没关系纯粹是触发条件太简单——扫一下公网 JSgrep 一下常见前缀就能命中。所以后文所有排查手段本质上都是围绕这些特征字符串展开的。你要做的不是记住每一家厂商的规范而是建立一套通用的扫描和审计能力。2. 敏感凭证混进打包产物的五条常见路径2.1 硬编码字符串最常见也最不应该第一条路径最直白把密钥直接写在代码里比如const apiKey sk-xxxxxxxx或者awsKey: AKIA...。这种写法在教程、示例代码、历史遗留项目里太常见了。很多人学 JavaScript 基础的时候跟着教材把 API Key 写进函数里跑通了一个 demo后来这个 demo 变成了线上项目key 也一路留了下来。硬编码的问题不只是泄露还包括没法轮转。你想想如果这个 key 散落在十多个文件里每次换密钥都得全项目搜索替换漏一个就前功尽弃。这也是为什么我后面会强调任何进入构建产物的字符串都要默认它是公开的。如果一个值当天就要上线用那就得设想它明天被全网看光能不能扛得住。排查硬编码非常简单搜字符串就行难点是人肉搜不完。我见过一个项目打包后的 bundle 有 2MB压缩之后各种字符混在一起最初的人是在编辑器的在文件中查找里搜 sk- 才发现的。2MB 能搜出来算运气好更大的产物就得用后面说的正则扫描工具。2.2 .env 和构建期环境变量我以为只有服务器能看到第二条路径最容易产生争议项目用了.env文件里面写好各种配置构建时注入到process.env。很多团队的认知是环境变量是大佬们管理的、服务器上才有的、用户不可能看到但实际工作机制完全不是这样。举一个最典型的 Vite 配置// vite.config.js export default defineConfig({ define: { process.env.APP_KEY: JSON.stringify(process.env.APP_KEY) } })这段配置的意思是构建的时候把代码里出现的process.env.APP_KEY全部替换成当时的环境变量值。假如你在 CI 或者构建机上设置了APP_KEYsk-live-abc123那么打包完的 JS 里就是一段字面量字符串sk-live-abc123。这个过程没有任何加密只是文本替换产物里的字符串和密钥原文一模一样。这里的关键逻辑是构建期环境变量和运行期环境变量是两回事。服务器端 Node.js 进程在运行时可以用process.env读取系统变量那没问题但浏览器里根本没有process这个对象所有打包工具做的是在构建时就把值焊死进产物里。很多人以为环境变量已经替他们保护了密钥其实只保护了密钥不在源码里却没保护密钥不在产物里。从泄露的角度看产物和源码根本没区别。2.3 示例配置与文档代码被当成生产配置第三条路径特别隐蔽不是主动写了密钥而是把示例代码里的假 key 和真 key 搞混了或者干脆把整个示例配置文件原封不动带进了生产。我有一次排查一个前端项目发现 bundle 里有一整段 GitHub 上的 SDK 示例代码里面的 appId、apiKey、甚至一个内存数据库的测试连接串全在。排查下来是因为有同事图省事把一个 demo 项目整个当作项目模板拷贝进来很多示例代码文件没有被引用到但构建工具把所有资源文件一起打进了产物。这种附带型泄露在打包文件里很常见——你以为没引用的文件万一被import()动态引入、或者被静态资源复制插件带进去就会悄无声息进入最终的 dist 目录。想堵住这条路靠写的时候小心不现实得靠构建产物的清单审查和定期的自动化扫描。下面排查部分我会给出具体做法。2.4 sourcemap 把源码原样送到了公网第四条路径在所有泄露路径里最冤代码里根本没有密钥但你把 sourcemap 文件上线了。sourcemap 是构建工具为了调试而生成的映射文件它会把压缩混淆前的原始代码一行不差地记录在里面。只要线上 JS 旁边挂着一个.js.map文件任何人用 DevTools 的 Sources 面板就能看到完整源码包括注释、原始文件名、没被打包器处理过的逻辑。sourcemap 泄露的可怕之处在于它不仅是泄露了几个字符串而是把整个源码仓库的可读版本都送了出去。哪怕源码里的密钥已经换成引用环境变量的写法源码里的其他业务逻辑、内部接口路径、算法实现也全暴露了。很多团队上线时喜欢图省事sourcemap: true一把梭本地调试是爽了线上攻击者也爽了。2.5 第三方 SDK 的测试 Key / 团队共享 Key第五条路径在近两年的 AI 应用浪潮里特别突出很多前端网页直接调用大模型 API官方文档为了演示方便会给一个测试 key前端工程师照着文档写完 demo 忘了换成自己账号的 key或者为了团队协作方便直接申请了一个共享 key 填进代码里。这类 key 最麻烦的地方在于权限边界模糊。共享 key 往往被多个项目复用你根本无法确定它到底在多少个 bundle 里出现过等到被盗刷才发现已经晚了。更麻烦的是很多 AI 平台的控制台里只能看到调用量找不到哪个前端页面泄露了我的 key因为线上页面把 key 明晃晃放在 JS 里任何人都能看到。3. 构建期环境变量注入为什么服务器上才有的变量会出现在 JS 里3.1 打包工具做的是一次文本替换不是加密前面说了构建期注入的本质是文本替换我再用一个完整链路演示一下。假设团队用 Webpack配置文件里写了这样一个插件// webpack.config.js const webpack require(webpack); module.exports { plugins: [ new webpack.DefinePlugin({ process.env.API_KEY: JSON.stringify(process.env.API_KEY), process.env.SENTRY_DSN: JSON.stringify(process.env.SENTRY_DSN) }) ] };源代码里你是这么写的function createClient() { return new ApiClient({ key: process.env.API_KEY }); }构建时 Webpack 会把代码里的process.env.API_KEY替换成某个字符串字面量。如果构建服务器上环境变量 API_KEY 的值是sk-original那么产物里的代码就会变成function createClient() { return new ApiClient({ key: sk-original }); }就是这么简单没有任何加密、混淆、权限校验。很多新手会把DefinePlugin误当成一种安全的注入方式实际它连脱敏都算不上。它唯一的作用是在源代码里隐藏敏感值让代码仓库看起来干净但一旦你把这个 key 视为机密它就不该出现在任何会输出到客户端的代码路径里。Vite 的define、Rollup 的rollup/plugin-replace、Next.js 的env配置本质上都是同一套机制。工具不同命运相同。3.2 为什么运行时读取比构建时写入安全既然构建期注入不安全那前端到底有没有真正意义上的运行时读环境变量答案是浏览器没有直接的能力但你可以自己实现一个运行时配置加载。做法很简单服务器在返回 HTML 之前往页面里注入一段全局配置script window.__APP_CONFIG__ { API_BASE: https://api.example.com, // 注意这个位置仍然不要放真正的密钥 RELEASE: 2024-05-01 }; /script前端代码运行时从window.__APP_CONFIG__读取配置而不是构建时从process.env读。这样做的优势是同一份打包产物部署到不同环境测试、预发、生产不需要重新构建只需要服务器端注入不同的配置。凡是经常要变、但是可以公开的值都适合走这条路。但请注意这种方式依然不解决密钥问题。只要值最终到了浏览器它就能被用户看到。区别只在于运行时配置让轮转和按环境隔离变得更加灵活而构建期注入会让换一个环境必须重新构建一次非常容易把某个环境的真实密钥误带到另一个环境的产物里。我见过最离谱的案例开发环境的 key 被注入进了生产 bundle因为发布的时候构建机上的环境变量优先级配错了。3.3 一个必须想清楚的判断标准给所有值和凭证做个分类就两类可以进浏览器 vs 不能进浏览器。进浏览器安全的标准是就算这个值被全世界看到也最多造成一点不痛不痒的副作用而不能进浏览器的标准是看一眼就能冒充你、扣你钱、读你数据。前端上线前把每一个配置项过一遍这个分类绝大多数泄露都能提前挡下。4. 实战排查定位打包文件里泄露凭证的完整套路4.1 先别扫全盘按正则盲扫如果现在你手上已经有一个现成的线上项目怀疑它可能泄露了凭证我的建议是先从盲扫开始不要打开 DevTools 人肉翻也不要第一反应就去改代码。准备好项目构建后的 dist 目录或者线上 CDN 正在服务的那些 JS 文件然后用正则表达式批量核对。在 JavaScript 里写扫描脚本其实就是最常见的字符串和正则处理学过 JavaScript 基础就能上手。我一般会先准备这样一张模式表目标正则模式AWS Access Key IDAKIA[0-9A-Z]{16}OpenAI / 常见大模型 Keysk-[A-Za-z0-9-]{20,}Google API KeyAIza[0-9A-Za-z_-]{35}Stripe 私钥sk_live_[0-9a-zA-Z]{24,}通用私钥块-----BEGIN (RSAGitHub Tokenghp_[A-Za-z0-9]{36}JWTeyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}然后用 Node.js 写个五分钟都不到的脚本const fs require(fs); const path require(path); const patterns [ { name: AWS Access Key, regex: /AKIA[0-9A-Z]{16}/g }, { name: OpenAI Key, regex: /sk-[A-Za-z0-9-]{20,}/g }, { name: Google API Key, regex: /AIza[0-9A-Za-z_-]{35}/g } ]; function scanFile(filePath) { const content fs.readFileSync(filePath, utf8); for (const { name, regex } of patterns) { const matches content.match(regex); if (matches) { console.log([命中] ${name} | 路径: ${filePath}); console.log( 匹配前缀: ${matches[0].slice(0, 12)}...); } } } function walk(dir) { const entries fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath path.join(dir, entry.name); if (entry.isDirectory()) walk(fullPath); else if (/\.(js|js\.map|json)$/.test(entry.name)) scanFile(fullPath); } } walk(./dist);别看脚本简单它能在几秒钟内扫完整目录。有一个细节要注意为了避免把一堆重复命中的实例刷屏我建议只输出每个文件里第一个命中的前缀人工确认之后再做全量导出。4.2 顺着泄露现场挖根因盲扫命中之后不要急着删字符串先找到根源。如果你在 dist 里的某个压缩 JS 文件中看到了sk-开头的内容先在编辑器里搜这个值找到它在源码里对应的位置。多数情况下你会看到三种结果源码里硬编码了直接改源。源码里引用的是process.env.XXX但构建产物里被替换成了字面量说明构建机上的环境变量注入出了问题或者某个.env.production文件被打进了产物。这个字符串来自node_modules里的某个依赖说明是依赖库自己带的配置这种情况比前两种更难处理因为你不知道升级依赖能不能解决。第三种情况往往被人忽略。我遇到过某个第三方视频播放器 SDK它内部默认内嵌了一个统计服务的 accessToken这个 token 是 SDK 厂商发给所有使用者的共享凭证就这么被打进了所有客户的 bundle。遇到这种情况你能做的只有联系厂商换 key、或者换接入方式本地代码怎么改都没用。4.3 顺着 sourcemap 挖回原始文件如果排查后发现线上存在 sourcemap 文件别犹豫优先把它下载下来。sourcemap 的价值不只是攻击者能用排查者更该用——它能直接还原出泄露字符串所在的完整源码文件。Node.js 生态里有个现成工具叫source-map配合source-map-cli可以按映射查找。你可以用这段代码还原npx source-map-cli parse --fileapp.abc123.js.map --original它会根据 map 文件中sourcesContent字段输出原始源码文件内容。拿到原始文件后再回到第 4.2 节的三种结果去分类处理。这里还有个判断经验如果 bundle 里泄露了密钥但线上没有 sourcemap就要意识到攻击者可能已经通过其他渠道看过源码了。因为 key 出现在产物里意味着它大概率也出现在某个前端代码仓库里Git 历史、npm 包发布记录、甚至同事的公开博客上都可能留有同样的凭证。4.4 用自动化工具做定期扫描别指望人一次性人工排查只能解决眼前的问题真正靠谱的做法是把扫描凭证变成自动化任务。社区里的开源工具我推荐三个各有侧重gitleaks主要扫 Git 历史能在 commit、分支、全仓库历史中找出已经提交过的密钥适合内部仓库巡检。trufflehog擅长扫高熵字符串也就是没有明显前缀、但是看起来像随机字符串的密钥。对云厂商自研 key 很有效。detect-secrets它的思路是在进入代码库之前建立一个基线文件之后新增的密钥能被自动标记。部署到 CI 里效果不错。我在本地和 CI 里的习惯是两条腿走路本地跑一次gitleaks detect --source . --no-git忽略 Git 历史只扫工作区CI 里再加一条gitleaks actions对每一个 PR 做增量扫描。这样既不会让历史垃圾把 CI 卡死又能保证新增代码不再带毒。还有一点别只扫源码构建产物也要定期扫。因为很多密钥是构建阶段才被注入的源码仓库里干净不代表 dist 干净。把 dist 目录加入扫描范围最好每天自动跑一遍命中就往工作群里发警报。5. 修复与防线从把钥匙收进后端到泄露了也没大用5.1 BFF 代理前端不碰密钥问题直接消失排查出泄露之后很多人第一反应是那我换一个 key 重新打包这种修复方式治标不治本三个月后同样的姿势再来一次。真正一劳永逸的做法是让前端代码永远不接触敏感凭证。最成熟、也最好落地的方案是 BFFBackend For Frontend代理。原理很简单前端页面不直接请求第三方 API而是请求你自己的后端服务后端拿到请求后在服务器端读取真正的密钥、访问第三方、再把结果返回给前端。密钥在整个链路里只存在于服务器环境变量浏览器端永远看不到。// 后端示例Node.js 代理 OpenAI 请求 const OpenAI require(openai); async function chat(req, res) { const client new OpenAI({ apiKey: process.env.OPENAI_API_KEY // 只存在于服务器 }); const completion await client.chat.completions.create({ model: gpt-4o-mini, messages: req.body.messages }); res.json(completion); } app.post(/api/chat, chat);前端代码里只有/api/chat这个地址没有sk-开头的任何东西。即使有人把前端 bundle 翻个底朝天也找不出一个能直接调用的第三方密钥。很多团队觉得加一层后端麻烦实际现在用 Serverless 函数、云函数、Edge 中间件都能很轻地实现。即使不能做完整 BFF至少做到前端只调用自己的域名由网关转发到第三方密钥留在网关配置里。5.2 给不得不放前端的 Key 上三道锁有些场景确实绕不开密钥进浏览器比如地图 SDK、支付流程里的 publishable key公开密钥、一些实时通信服务。这类 key 在设计上已经默认是公开的风险等级低但仍要按规定使用。面对这类 key我给你三个实际管用的约束第一平台侧开启来源限制。Google 地图 API Key 可以绑定 HTTP referrer只允许你的域名调用其他云服务也大多支持 IP 白名单、App 限制。泄露出来别人拿去用在来源校验这关就被挡下。第二最小权限原则。给前端的 key 只开通它必须的权限千万别用管理员权限、也别让一个 key 能调用所有服务。很多平台支持受限 key或作用域打开它。第三短周期轮转。即使 key 泄露如果它几天就失效一次攻击者能利用的窗口就很有限。定时轮转听起来繁琐但现在多数平台的 SDK 都支持多 key 共存、平滑切换。5.3 提交前卡住pre-commit 与 CI 扫描前面讲的都是泄露之后怎么发现、怎么收口。最好不漏的做法是把检查提前到代码提交之前。我现在的项目里都会装一套 pre-commit 钩子用husky配合gitleaks做本地扫描npx husky add .husky/pre-commit npx gitleaks protect --staged --redact意思是在 commit 之前只对 git 暂存区里新增、修改的内容做扫描如果命中疑似密钥直接拦截。这样你 99% 的手滑都会被挡在仓库之外。CI 里的扫描就更严格一点。每个 PR 合并前跑一遍gitleaks打 tag 发布前再跑一遍detect-secrets全量扫。值得一提是仓库里的.env.example文件里的假 key 容易误报你可以给工具配一个 allowlist但请记住任何真实 key 都不允许进 allowlist。宁可误报多几次也不要为了图省事放行一个敏感值。5.4 上线前清单把发布产物也纳入检查很多团队的发布流程里代码仓库的扫描做了构建产物的扫描却漏了。编码上我最推荐的做法是构建完成之后、推送 CDN 之前加一个产物凭证扫描的步骤。CICD 流水线里可以这么写# 1. 构建 npm run build # 2. 扫描产物 npx gitleaks detect --source ./dist --no-git # 3. 如果命中直接终止发布 if [ $? -ne 0 ]; then echo 检测到敏感凭证构建已中止 exit 1 fi # 4. 确认没有 sourcemap 入库 find ./dist -name *.js.map -delete第 4 步特别提一句如果团队已经养成了调试依赖 sourcemap 的习惯完全可以在本地开构建工具开启 sourcemap、CI 发布时强制关闭或者在 CDN 层面对.map文件做身份校验。把 .map 当普通静态资源直接传上去等于把源代码晒在了公网。6. 我踩过的坑和现在的默认动作说一个我自己的真实教训。有次我负责重构一个老管理后台构建配置里从 CI 上读取了一个全局变量来注入客服系统的 appKey。这套配置在测试环境跑了半个月一直没问题。上线那天运维告诉我 CI 机器上有一个全局环境变量APP_KEY是生产真值但配置读取的优先级里先读了项目根目录的.env.local里面是开发环境的假 key。两边一混合最终打进生产 bundle 的是一个已经被轮转过的旧 key。这个问题的严重性不在于那个 key 本身而在于我彻底反思了整套流程如果我们把任何进入客户端的东西都默认公开作为前提就会提前发现团队根本不该让一个全局环境变量从 CI 流进前端 bundle。从那以后我给自己和团队定了三个默认动作第一凡是第三方服务的密钥一律不进前端构建链路。哪怕只是为了跑通一个 demo也别图省事。demo 跑通之后立刻转成 BFF 模式接后端。第二发布前必扫产物。我把npm run build npm run scan写进了发布脚本只要命中任何正则模式构建就失败。偶尔会因为误报卡一下流程但和泄露一次相比这点成本几乎可以忽略。第三把 sourcemap 当成源码对待。本地保留随便开线上要么关掉、要么加访问控制。后端强制删 .map 文件不是多此一举它是我见过投入产出比最高的防泄露手段。最后再分享一个小技巧如果你发现自己团队的打包文件里已经出现过一次敏感凭证别只清理这一次调出工具扫描记录把所有历史包都扫一遍。泄露过的东西往往不止在一个版本里出现。宁可多花一晚上把历史包袱翻干净也不要因为眼下没出事就把问题留给下一次发布。
返回列表