前端隐私资产保护
网页里展示的一切,浏览器都拿到了完整字节:一个只允许「在本站展示、禁止再分发」的 3D 模型,一套买了网页授权的字体,一批付费图库的原图。把它们放进静态目录,就等于开放了下载地址。这篇文章用一次真实改造记录能做到哪一步、做不到哪一步,以及每一步的代价。
先划清边界
**只要资源在浏览器里渲染,它的字节就必须传到浏览器。**没有「只在内存里拉取、不经过网络」的方法,网络面板永远看得到请求。因此前端能做的不是「防止获取」,而是抬高获取成本,并把能挡住的对象说清楚:
| 对象 | 手段 | 前端能否挡住 |
|---|---|---|
| 直接打开地址、右键另存为 | 复制 URL | 能 |
| 外站盗链 | <img> / <a> 引用 | 能 |
| 网络面板另存响应 | DevTools | 能,前提是响应本身不是可用文件 |
| 读前端代码复现还原逻辑 | 脚本 | 不能,只能增加工作量 |
| 从 GPU 抓取几何与贴图 | WebGL 调试工具 | 不能 |
目标读者是最后一行以外的人。如果资产的价值高到值得有人写脚本,前端手段不够,需要授权、水印和法务。
案例背景
一个个人站首页放了一个第三方 3D 角色模型:GLB 格式,约 7 MB,授权条件是「可在本站展示,禁止第三方再分发」。最初的做法是把 model.glb 放进 public/models/,页面用 GLTFLoader.load(url) 加载。任何人访问 /models/model.glb 都能拿到完整文件。
期间还有一个常见误解:把代码仓库设为私有。私有仓库只隐藏源码与提交历史,构建产物照样公开部署,资源的可下载性没有任何变化。
改造分三层,每一层单独就能生效,叠加后覆盖上表前三行。
第一层:站上不存在可直接打开的文件
思路:源文件不进静态目录;构建期切片、打乱、按内容哈希命名;浏览器并行取回分片,在内存里还原,再交给加载器的 parse 方法而不是 load(url)。
构建期
// scripts/pack-model.mjs(Node,dev/build 前运行)import { createHash } from 'node:crypto';import { readFileSync, writeFileSync } from 'node:fs';import { scramble } from '../src/lib/pack-core.mjs';
const source = readFileSync('src/assets/models/model.glb');const digest = createHash('sha256').update(source).digest('hex');// 密钥由源文件哈希派生:文件不变则分片与文件名都不变,浏览器缓存才能长期命中const key = createHash('sha256').update(`pack:${digest}`).digest();
const PART = 1024 * 1024;const parts = [];for (let offset = 0, i = 0; offset < source.length; offset += PART, i++) { const chunk = new Uint8Array(source.subarray(offset, offset + PART)); scramble(chunk, key, i); const name = `${createHash('sha256').update(chunk).digest('hex').slice(0, 20)}.bin`; writeFileSync(`public/models/${name}`, chunk); parts.push(name);}writeFileSync('src/generated/model-manifest.json', JSON.stringify({ size: source.length, key: key.toString('hex'), parts }));scramble 是一段对称的 XOR,打乱与还原是同一个函数,Node 与浏览器共用同一个模块,不会各写一份后漂移:
export function scramble(bytes, key, part) { const salt = (part * 0x9e3779b1) >>> 0; for (let i = 0; i < bytes.length; i++) { bytes[i] ^= key[i % key.length] ^ ((salt >>> ((i & 3) * 8)) & 0xff) ^ (i & 0xff); } return bytes;}分片名取自打乱后内容的哈希,清单(manifest.json)由页面组件在服务端读入,写进一个 data-* 属性。产物目录和清单都不入版本控制。
浏览器端
async function fetchPack(pack: Pack, signal: AbortSignal, onProgress: (n: number) => void) { const key = hexToBytes(pack.key); let loaded = 0; const chunks = await Promise.all( pack.parts.map(async (name, index) => { const res = await fetch(`${pack.base}${name}`, { signal }); if (!res.ok) throw new Error(`part ${index}: ${res.status}`); // 按流读取:8 片同时下载时,若等整片完成再报进度,慢速链路上进度会长时间停在 0 const reader = res.body!.getReader(); const pieces: Uint8Array[] = []; for (;;) { const { done, value } = await reader.read(); if (done) break; pieces.push(value); loaded += value.byteLength; onProgress(loaded); } return concat(pieces); }) ); const out = new Uint8Array(pack.size); let at = 0; chunks.forEach((chunk, i) => { out.set(scramble(chunk, key, i), at); at += chunk.byteLength; }); return out.buffer;}
const buffer = await fetchPack(pack, abort.signal, updateLabel);const gltf = await loader.parseAsync(buffer, pack.base);这一层的真实效果
- 网络面板里是 8 个
.bin,每一片开头都不是glTF魔数,另存下来打不开。 - 站上不存在
.glb地址。 - 密钥和还原逻辑都在前端包里。**这是混淆,不是加密。**有人愿意读代码,十几分钟就能拼回来。
第二层:服务端拒绝非本站来源
分片仍是静态文件,复制地址就能下。让服务器只放行「本站页面里脚本发起的请求」:
location ^~ /models/ { root /srv/site; valid_referers example.com; set $deny 1; if ($http_sec_fetch_site = "same-origin") { set $deny 0; } if ($invalid_referer = "") { set $deny 0; } if ($http_sec_fetch_mode = "navigate") { set $deny 1; } if ($deny) { return 403; } # 文件名是内容哈希,内容变则名字变,可放心长缓存 add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404;}判定依据:
Sec-Fetch-Site: same-origin:现代浏览器为同源请求自动附加,页面脚本无法修改。Referer指向本站:作为关掉 Fetch Metadata 的旧浏览器与隐私扩展的兜底。Sec-Fetch-Mode: navigate:地址栏直接打开,无条件拒绝。
两个容易踩的点:
location内一旦出现add_header,server级的那组安全头(HSTS、X-Content-Type-Options等)就不再继承,需要在这里重复。if在location里只建议配合set与return使用,其他指令放进if块行为不可预期。
上线前用本地容器跑一遍用例,比读文档可靠:
| 请求 | 期望 |
|---|---|
无任何头的 curl | 403 |
Sec-Fetch-Mode: navigate | 403 |
| 外站 Referer | 403 |
Sec-Fetch-Site: same-origin | 200 |
| 仅本站 Referer | 200 |
| 不存在的分片 | 404 |
首页 / | 200(确认没误伤) |
同样要说明:请求头可以伪造。这一层挡的是直链和盗链,不挡脚本。
第三层:把规则写进部署探针
规则只在配置文件里,下一次重构 nginx 或换镜像时就会悄悄失效。把它变成 CI 部署后的断言:
name=$(curl -s https://example.com/ | grep -oE '[0-9a-f]{20}\.bin' | head -n1)part="https://example.com/models/$name"test "$(curl -s -o /dev/null -w '%{http_code}' "$part")" = 403test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Sec-Fetch-Site: same-origin' "$part")" = 200test "$(curl -s -o /dev/null -w '%{http_code}' https://example.com/models/model.glb)" = 404三条断言分别覆盖:裸请求被拒、同源请求可用、旧的整文件地址不再存在。任何一条失败,部署流水线红灯。
顺带得到的,和没有得到的
- 长缓存:分片按内容哈希命名,可以设一年
immutable;模型一更新文件名自动变化,不存在缓存过期问题。回访零下载。 - 进度反馈:按流读取后,进度条随字节到达更新,而不是等某一片整片完成。
- 性能:分片本身几乎不提速。HTTP/2、HTTP/3 下一个 7 MB 文件和 8 个 1 MB 文件的总耗时差别很小。收益来自缓存,不是切片。
还能再往上走的选项
按成本递增:
- 短时签名地址:页面渲染时生成带过期时间与 HMAC 的分片 URL,服务端校验(nginx 可用
secure_link模块)。复制出来的地址几分钟后失效。需要服务端参与,纯静态站要额外一层。 - 限速与配额:同一 IP 短时间内取片超过阈值即拒绝,针对批量抓取。
- 可追溯水印:对几何做肉眼不可见的微扰,或在贴图里嵌入站点标识。挡不住下载,但泄露后能证明来源。
- DRM:浏览器的 EME 只覆盖音视频,3D 模型、字体、图片没有标准方案。
反过来,如果资产本身是 CC0 或允许再分发,以上全部不必做。混淆换来的门槛,是用可读性、构建复杂度和调试成本付的。