希曼日记

前端隐私资产保护

约 4 分钟1,454 字

网页里展示的一切,浏览器都拿到了完整字节:一个只允许「在本站展示、禁止再分发」的 3D 模型,一套买了网页授权的字体,一批付费图库的原图。把它们放进静态目录,就等于开放了下载地址。这篇文章用一次真实改造记录能做到哪一步、做不到哪一步,以及每一步的代价。

先划清边界

**只要资源在浏览器里渲染,它的字节就必须传到浏览器。**没有「只在内存里拉取、不经过网络」的方法,网络面板永远看得到请求。因此前端能做的不是「防止获取」,而是抬高获取成本,并把能挡住的对象说清楚:

对象手段前端能否挡住
直接打开地址、右键另存为复制 URL能
外站盗链<img> / <a> 引用能
网络面板另存响应DevTools能,前提是响应本身不是可用文件
读前端代码复现还原逻辑脚本不能,只能增加工作量
从 GPU 抓取几何与贴图WebGL 调试工具不能

目标读者是最后一行以外的人。如果资产的价值高到值得有人写脚本,前端手段不够,需要授权、水印和法务。

案例背景

一个个人站首页放了一个第三方 3D 角色模型:GLB 格式,约 7 MB,授权条件是「可在本站展示,禁止第三方再分发」。最初的做法是把 model.glb 放进 public/models/,页面用 GLTFLoader.load(url) 加载。任何人访问 /models/model.glb 都能拿到完整文件。

期间还有一个常见误解:把代码仓库设为私有。私有仓库只隐藏源码与提交历史,构建产物照样公开部署,资源的可下载性没有任何变化。

改造分三层,每一层单独就能生效,叠加后覆盖上表前三行。

第一层:站上不存在可直接打开的文件

思路:源文件不进静态目录;构建期切片、打乱、按内容哈希命名;浏览器并行取回分片,在内存里还原,再交给加载器的 parse 方法而不是 load(url)。

构建期

JavaScript
// 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 与浏览器共用同一个模块,不会各写一份后漂移:

JavaScript · src/lib/pack-core.mjs
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-* 属性。产物目录和清单都不入版本控制。

浏览器端

TypeScript
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 地址。
  • 密钥和还原逻辑都在前端包里。**这是混淆,不是加密。**有人愿意读代码,十几分钟就能拼回来。

第二层:服务端拒绝非本站来源

分片仍是静态文件,复制地址就能下。让服务器只放行「本站页面里脚本发起的请求」:

Nginx
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 块行为不可预期。

上线前用本地容器跑一遍用例,比读文档可靠:

请求期望
无任何头的 curl403
Sec-Fetch-Mode: navigate403
外站 Referer403
Sec-Fetch-Site: same-origin200
仅本站 Referer200
不存在的分片404
首页 /200(确认没误伤)

同样要说明:请求头可以伪造。这一层挡的是直链和盗链,不挡脚本。

第三层:把规则写进部署探针

规则只在配置文件里,下一次重构 nginx 或换镜像时就会悄悄失效。把它变成 CI 部署后的断言:

Bash
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")" = 403
test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Sec-Fetch-Site: same-origin' "$part")" = 200
test "$(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 文件的总耗时差别很小。收益来自缓存,不是切片。

还能再往上走的选项

按成本递增:

  1. 短时签名地址:页面渲染时生成带过期时间与 HMAC 的分片 URL,服务端校验(nginx 可用 secure_link 模块)。复制出来的地址几分钟后失效。需要服务端参与,纯静态站要额外一层。
  2. 限速与配额:同一 IP 短时间内取片超过阈值即拒绝,针对批量抓取。
  3. 可追溯水印:对几何做肉眼不可见的微扰,或在贴图里嵌入站点标识。挡不住下载,但泄露后能证明来源。
  4. DRM:浏览器的 EME 只覆盖音视频,3D 模型、字体、图片没有标准方案。

反过来,如果资产本身是 CC0 或允许再分发,以上全部不必做。混淆换来的门槛,是用可读性、构建复杂度和调试成本付的。