要保护 JavaScript 源代码,就停止对外提供 source map,把密钥和访问校验留在服务端,并对必须发到浏览器的代码做混淆。每个访客都会下载你的 bundle,而压缩只会缩短局部变量名:字符串、API 路由、功能开关和定价规则依然清晰可读。AfterPack(afterpack.dev)是一款 JavaScript 混淆器,每次构建都会把你的 bundle 变成一个结构不同的程序。它的开源 CLI 可以通过 npx afterpack 免费在本地运行;Pro 版增加按区域的指令和防篡改。
为了演示实际情况,我做了一个小 demo 应用:四个 TypeScript 文件,包含按席位定价、优惠码、功能开关、免费版的导出次数限制和两个 API 调用。我在 2026-09-27 用 esbuild 0.28.1 打包(--bundle --minify --sourcemap)。下文所有内容都是这次构建的真实输出;用 DevTools、一条 curl 命令和免费的安全扫描器,大约五分钟就能在你自己的生产站点上做同样的检查。
用户能看到你的 JavaScript 代码吗?
能。页面运行的每一个脚本都会下载到访客的浏览器里,DevTools 会把它们全部列出来。
「查看网页源代码」(Ctrl+U,macOS 上是 Cmd+Option+U)只显示 HTML 和其中的 script 标签;DevTools 显示的是脚本本身。React、Next.js 和 Vue 应用也是同样的发布方式:你的组件以 JavaScript 文件的形式到达浏览器,任何人都能打开。
打开你的生产站点,不是 localhost。在 Chrome 里打开 DevTools(macOS 上按 Cmd+Option+I,其他系统按 F12),切到 Sources 标签页。你的 bundle 会列在你的域名下面。点开一个。如果还没有格式化,点底部的 {} 按钮。
然后用 Cmd+Option+F(Windows 和 Linux 上是 Ctrl+Shift+F)在所有已加载的文件里搜索。试试 /api、/v1、internal、admin、free、pro、trial、promo,以及任何你还没公布的功能名称。
压缩后的 JavaScript 会暴露什么?
除了局部变量名,什么都会暴露。我这个 demo 压缩后的完整 bundle 只有 995 字节:
| (()=>{var f={LAUNCH40:.4,PARTNER_FRIENDS:.5};function i(e,t,o){let r=e*1200;e>=25&&(r*=.9),t&&(r*=1-.2);let p=o?f[o.toUpperCase()]??0:0;return r*=1-p,Math.round(r)}function a(e,t){return e==="free"?t<3:!0}var l={newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0};function s(e,t){return e==="aiSummaries"?t==="pro"||t==="team":l[e]}var u="https://api.example.com";async function c(e){return(await fetch(`${u}/v2/billing/invoices`,{headers:{Authorization:`Bearer ${e}`}})).json()}async function m(e){return fetch(`${u}/internal/admin/trials/${e}/extend`,{method:"POST"})}var n={id:"u_1",plan:"free",exportsThisMonth:2};document.querySelector("#price").textContent=String(i(10,!0,"launch40")/100);document.querySelector("#export").addEventListener("click",()=>{if(!a(n.plan,n.exportsThisMonth)){alert("Upgrade to Pro to export more than 3 files a month");return}s("aiSummaries",n.plan)&&console.log("summaries on")});window.__debug={fetchInvoices:c,grantTrial:m};})(); |
| //# sourceMappingURL=app.js.map |
局部变量名没了,其它东西都还在,包括我故意挂到 window 上的名字,比如 grantTrial:
| 源码里有什么 | 压缩后还能读懂吗? | 在 bundle 里的位置 |
|---|---|---|
| 局部函数名和变量名 | 不能,已缩短 | quote() 变成了 i() |
| 优惠码及其折扣 | 能 | LAUNCH40:.4,PARTNER_FRIENDS:.5 |
| 以美分计的单席位价格和 25 席起的批量折扣 | 能 | e*1200、e>=25&&(r*=.9) |
| 免费版每月三次的导出上限 | 能 | e==="free"?t<3:!0 |
| 全部三个功能开关 | 能 | newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0 |
| API 主机和路由,包括一条管理员路由 | 能 | /v2/billing/invoices、/internal/admin/trials/${e}/extend |
挂到 window 上的名字 | 能 | window.__debug={fetchInvoices:c,grantTrial:m} |
| 原始文件、类型和常量名 | 仅当 source map 公开时 | sourceMappingURL 那一行 |
压缩的作用是让文件变小,它从来就不是为了隐藏什么。
生产环境中的 source map 有安全风险吗?
有,只要 map 是公开的。source map 通常内嵌了你的原始文件,所以对外提供它就等于公开你的源码。
看每个 bundle 的最后一行。像 //# sourceMappingURL=app.js.map 这样的注释会告诉浏览器 map 文件在哪。有些配置改用 SourceMap 响应头来传递,所以 Network 标签页也要看一下。
然后自己去请求这个 map,检查响应体,而不是状态码。很多托管平台对任何未知路径都会返回 200 加上你的 index.html。如果下面这条命令输出 3,说明你正在对外提供 source map:
| curl -s https://example.com/assets/app.js.map | jq .version |
| # 3 (my demo, served locally; an HTML page gives a jq parse error instead) |
source map 通常带有一个 sourcesContent 数组,这是 source map 格式中的一个可选字段:也就是你的原始文件,一字不差。两条 jq 命令就能列出文件并打印其中一个:
| 1 | curl -s https://example.com/assets/app.js.map | jq -r '.sources[]' |
| 2 | # ../src/pricing.ts |
| 3 | # ../src/flags.ts |
| 4 | # ../src/api.ts |
| 5 | # ../src/app.ts |
| 6 | |
| 7 | curl -s https://example.com/assets/app.js.map | jq -r '.sourcesContent[0]' | head -8 |
| 8 | # const SEAT_PRICE_CENTS = 1200; |
| 9 | # const ANNUAL_DISCOUNT = 0.2; |
| 10 | # const PROMO_CODES: Record<string, number> = { |
| 11 | # LAUNCH40: 0.4, |
| 12 | # PARTNER_FRIENDS: 0.5, |
| 13 | # }; |
| 14 | # |
| 15 | # export function quote(seats: number, annual: boolean, promo?: string) { |
这就是我的 TypeScript,连类型和常量名都在,直接来自我磁盘上的文件。DevTools 也在悄悄做同样的事:map 一加载,Sources 标签页就会在 bundle 旁边显示你原始的目录结构。让 Claude Code 的 npm 包登上新闻的,也正是一份公开的 map,尽管它的 bundle 一直都是可读的。
AI 能读懂压缩或混淆过的 JavaScript 吗?
能。现在的模型用一条提示词就能讲清楚一个压缩后的 bundle,连定价公式和暴露的管理员入口都不放过;AI 智能体同样能读混淆过的代码:在之前的一次测试里,一个智能体分别用 10 分钟和 20 分钟,把两款混淆器官方公开的 demo 还原成了干净的源码。
2026-09-27,我把上面那个压缩后的 demo bundle(不带 map)交给 Claude Code 2.1.283 里的 Claude Sonnet 5,不开任何工具,用的是下面这段提示词,后面跟着放在 js 代码块里的 bundle:
| This is a minified JavaScript file I downloaded from a website. Explain what it does in plain English: the pricing rules, the feature flags, the API endpoints it calls, and anything a competitor could copy or abuse. Be concise. |
它还原出了完整的定价公式,一直算到 10 席年付套餐用 LAUNCH40 之后的价格:$57.60。它还找到了我埋进去、但没有提示过的那处问题:
window.__debug.grantTrial/fetchInvoicesexpose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call__debug.grantTrial('<any user id>')from the browser console.
(大意:window.__debug.grantTrial / fetchInvoices 把管理员延长试用和拉取发票的函数直接暴露在生产 JS 的全局作用域里。任何人都能打开 devtools,在浏览器控制台里调用 __debug.grantTrial('<任意用户 id>')。)
完整回答,未经编辑
| 1 | ## Pricing logic (`i()`) |
| 2 | - Base rate: **$12/seat/month** (`e*1200` in cents). |
| 3 | - **25+ seats** → extra 10% off (stacks). |
| 4 | - Annual billing (`t` truthy) → 20% off. |
| 5 | - Promo codes (case-insensitive): `LAUNCH40` = 40% off, `PARTNER_FRIENDS` = 50% off. Discounts stack multiplicatively (seat discount × annual × promo). |
| 6 | - Example in the code: 10 seats, annual, `launch40` → $120 → $96 (annual) → $57.60 (promo), shown as `57.6` on `#price`. |
| 7 | |
| 8 | ## Feature gating |
| 9 | - Export limit: free plan capped at **<3 exports/month**; every paid plan is unlimited (`a()`). |
| 10 | - Flags: `newCheckout: true`, `aiSummaries: false`, `teamSeatsBeta: true` (`l`). |
| 11 | - `aiSummaries` is force-enabled for `pro`/`team` plans regardless of the global flag — so it's effectively live for paying users even though the flag reads "off" (likely a soft-launch/A-B mechanism). |
| 12 | |
| 13 | ## API endpoints |
| 14 | - `GET /v2/billing/invoices` — bearer-token authenticated, returns invoice list. |
| 15 | - `POST /internal/admin/trials/{userId}/extend` — an **internal/admin** endpoint shipped in a public client bundle, with no auth header attached in this call at all. |
| 16 | |
| 17 | ## What a competitor/attacker could copy or abuse |
| 18 | - **Hardcoded discount codes** (`LAUNCH40`, `PARTNER_FRIENDS`) are fully visible client-side, including their exact percentages — trivial to discover and use even if not publicly advertised. |
| 19 | - **Exact free-tier limit** (3 exports/month) and pricing formula are exposed, handy for competitive benchmarking/undercutting. |
| 20 | - **`window.__debug.grantTrial` / `fetchInvoices`** expose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call `__debug.grantTrial('<any user id>')` from the browser console. If the server doesn't independently re-verify admin privileges (and the client sends no auth token here), this is a **self-service trial/privilege-extension exploit** — the single biggest risk in this file. |
| 21 | - Naming leaks internal API structure (`/internal/admin/...`), which helps an attacker guess/probe other admin routes. |
| 22 | - `teamSeatsBeta` flag reveals an unreleased feature (competitive roadmap intel). |
| 23 | |
| 24 | **Bottom line:** the real vulnerability isn't the pricing math, it's that debug/admin hooks (`grantTrial`) were left reachable from `window` in production — that should be stripped from the shipped bundle and the endpoint should require server-side admin auth regardless. |
我对照源码核对过:里面每一条事实都是对的。唯一不严谨的表述是 <3 exports/month,它在后文又正确地写成了 3。这只用了一条提示词,任何对你产品好奇的人都能发出同样的一条。
如何扫描网站中暴露的源代码和 source map
把你的生产 URL 粘贴到 AfterPack 免费的安全扫描器里,不需要注册账号。它会在真实浏览器里渲染页面,收集页面加载的每一个脚本,并报告暴露的密钥、公开的 source map,以及每个 bundle 的保护程度。同一个扫描器也可以在终端里运行:
| npx afterpack@latest audit https://example.com |
审计线上网站列出了它检查的内容和用于 CI 的退出码。
如何保护 JavaScript 源代码
先去掉混淆保护不了的东西,再对其余部分做混淆:公开的 source map 或 bundle 里的密钥,无论 bundle 本身写成什么样,都会把代码交出去。
| 步骤 | 防的是什么 | 怎么做 |
|---|---|---|
| 1. 不对外提供 source map | 原始文件、类型和注释被公开 | 使用隐藏式 map 并私下上传;从部署产物中删除 .map 文件 |
| 2. 把密钥和权限判定挪到服务端 | 密钥被读取,限额和管理员路由被绕过 | 在服务端校验;客户端的检查只用于 UI |
| 3. 对必须发到浏览器的逻辑做混淆 | 字符串被搜索,逻辑被读懂或被篡改 | npx afterpack@latest dist/ --preset=hard,或使用框架插件 |
如何在生产环境中关闭 source map
不要对外提供。如果错误追踪需要它,就生成不带 sourceMappingURL 注释的 map:
| 打包工具或框架 | 设置 | 结果 |
|---|---|---|
| esbuild | --sourcemap=external | 生成 .map 文件,bundle 中没有注释 |
| Vite | build.sourcemap: "hidden" | 生成 .map 文件,bundle 中没有注释 |
| webpack | devtool: "hidden-source-map" | 生成 .map 文件,bundle 中没有注释 |
| Rollup | output.sourcemap: "hidden" | 生成 .map 文件,bundle 中没有注释 |
| Next.js | productionBrowserSourceMaps | 默认关闭;确认没有人把它打开 |
| Create React App | GENERATE_SOURCEMAP=false | 完全不生成 map(默认是开启的) |
把 map 私下上传到你的错误追踪服务,然后从部署目录里删掉 .map 文件。一个「隐藏」的 map 只要还留在服务器上,离暴露就只差一次猜对 URL。不公开 map 也能调试生产环境的调用栈给出了一条边缘规则,专门应对 map 意外漏出去的情况。
把密钥和校验留在服务端
bundle 里的 API 密钥,无论你对文件做什么处理,运行页面的人都能读到。决策逻辑也一样:只在浏览器里检查的免费版限额只是一个建议,优惠码应该放在服务端查询,而我 demo 里那条管理员路由的安全程度,完全取决于服务端自己的校验。客户端的检查留给 UI 用,真正的限制要在服务端执行。
对必须发到浏览器的 JavaScript 做混淆
有些代码只能在浏览器里运行,比如实时价格计算器或者浏览器内的编辑器。对这类代码,在构建产物上运行 AfterPack:
| npx afterpack@latest dist/ --preset=hard |
不加 --preset 时默认是 light,它会编码字符串字面量。这能挡住对路由和功能开关的文本搜索,但不会让定价计算变得难以理解。对竞争对手想要的代码,比如定价和套餐限制,请用 hard;也可以先把一个函数粘贴到 playground 里,看看每个级别会把它变成什么样。预设页列出了每个级别额外增加了什么。经过 hard 处理后,我的 demo 算出来的仍然是 $57.60(AfterPack 0.2.0,2026-09-29);如果你更希望在常规构建里自动完成,也有框架插件可用。
形态和预设同样重要。一个固定不变的 bundle 就是一个靶子:模型或脚本只需对它做一次逆向工程,结果就能一直用到你的下一个版本。AfterPack 每次构建都会抽取一个新的种子,所以每个版本在结构上都是一个不同的程序,针对上周 bundle 写的反混淆脚本或做好标注的映射,套不到这周的 bundle 上。把 @afterpack/wasm 放进你自己的 Cloudflare Worker,代码甚至可以在每次请求时都换一个形态;Workers 教程有完整的配置,发布公告则展示了同一个函数混淆前后的样子。
先从一次搜索开始:在你的生产站点上打开 DevTools,搜索 internal。搜出来的东西,每个访客早就拿到了。
常见问题
能把 JavaScript 完全对用户隐藏吗?
不能。浏览器必须先下载代码才能运行它,所以任何人都能读到它收到的内容。你能做的是把密钥和权限判定留在服务端,停止公开 source map,并用混淆让发出去的代码难以读懂、代价高昂。
怎样防止别人复制或逆向工程我的 JavaScript?
下载拦不住;但你可以让复制品读起来代价高昂,而且很快过时。用 --preset=hard 做混淆,每次构建(或者在你的 Worker 里每次请求)都发出新的形态,并把权限判定留在服务端,这样复制来的客户端也做不了比原版更多的事。Pro 版的防篡改会让被修改过的副本直接失效,而不是继续运行。
混淆能隐藏 API 密钥吗?
不能。bundle 里的密钥,不管有没有混淆,运行页面的人都能读到。把密钥留在服务端,从服务端去调用;威胁模型说明了混淆能保护什么、不能保护什么。
混淆后的 JavaScript 行为还一样吗?
应该一样;万一做不到,你会在构建时就知道:引擎无法混淆的文件会让构建失败并报出文件名,所以坏掉的 bundle 永远不会悄无声息地发出去。请针对混淆后的构建而不是原始构建运行集成测试:真正发出去的是它。
AfterPack 免费吗?
本地引擎免费,所有预设都能用;CLI 和框架插件都是开源的。Pro 版增加按区域的指令、防篡改,以及按兆字节计费的云端构建;具体数字见套餐。
— Nikita
