冷门知识点 | 安全防护 | 领导要求加个水印保护公司敏感信息,你两分钟写完
position: fixed + opacity: 0.1,结果运营同事打开 DevTools 一键删除......今天就把水印做到「删不掉」。
问题场景
运营团队正在使用一个内部运营后台,页面上展示着千万级 GMV 数据。安全部门要求所有数据页面必须带上员工工号水印。
第一天:PM:「加个水印」 你:用 position: fixed 的 div 覆盖全屏,加 pointer-events: none,搞定。
第二天:运营反馈:「我用 F12 把水印那个 div 删掉了,截图发给老板了......没问题吧?」 你:「......」
内核中 CSS-only 水印在 DevTools 面前就是纸糊的。看似「有」实则「无」,用户只需随手删一个 DOM 节点就破了。
原因分析
前端水印常见的「纸上谈兵」实现方式:
html
<!-- ❌ 最脆弱的水印 -->
<div id="watermark" style="position:fixed;inset:0;pointer-events:none;opacity:0.08;z-index:99999;">
<span>机密-张三-2026-07-23</span>
</div>
三大致命伤:
- DOM 可删除 --- DevTools 选中节点按 Delete 就没了
- DOM 可隐藏 ---
display: none、visibility: hidden一键隐身 - CSS 可覆盖 --- 修改
z-index、opacity直接失效
更「高级」一点的多层水印 + 脚本定时重绘,也只能防住普通用户。只要懂一点 JS debugging(断点除定时器、删除 DOM),一样破防。
解决方案
方案一:Canvas 全屏覆盖(初级加固)
用 Canvas 绘制水印渲染为背景图片,通过 background-repeat 铺满,DOM 路径上无从删除。
html
<!DOCTYPE html>
<html>
<head>
<style>
.watermarked {
/* 背景图将在 JS 中设置 */
background-repeat: repeat;
background-size: 200px 150px;
}
</style>
</head>
<body id="app">
<div>敏感数据区域</div>
<script>
function createWatermark(text = '机密-张三-2026-07-23') {
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 150;
const ctx = canvas.getContext('2d');
ctx.rotate(-20 * Math.PI / 180); // 旋转 -20 度
ctx.font = '14px Microsoft YaHei';
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillText(text, 20, 80);
document.getElementById('app').style.backgroundImage =
`url(${canvas.toDataURL('image/png')})`;
}
createWatermark();
</script>
</body>
</html>
优点: DOM 树里找不到水印元素,无法「Delete」删除 缺点: 修改 background-image CSS 属性即可覆盖,或截图后简单 PS 擦除
方案二:Canvas + MutationObserver 双重监听(中级防护)
在方案一基础上,加入 MutationObserver 监听 DOM/样式篡改,一旦发现水印被篡改立即重建。
javascript
function setupWatermark(text) {
const target = document.getElementById('app');
let watermarkStyleId = 'wm-style';
function applyWatermark() {
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 150;
const ctx = canvas.getContext('2d');
ctx.rotate(-20 * Math.PI / 180);
ctx.font = '14px Microsoft YaHei';
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillText(text, 20, 80);
target.style.backgroundImage = `url(${canvas.toDataURL()})`;
}
// 第一次应用
applyWatermark();
// MutationObserver 监听 background-image 是否被修改
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' &&
mutation.attributeName === 'style') {
const bg = target.style.backgroundImage;
if (!bg || bg === 'none') {
applyWatermark(); // 被删了?马上恢复!
}
}
}
});
observer.observe(target, {
attributes: true,
attributeFilter: ['style'],
});
// 额外防御:定时检查(防 MutationObserver 被断开)
const interval = setInterval(() => {
const bg = target.style.backgroundImage;
if (!bg || bg === 'none') {
applyWatermark();
}
}, 1000);
// 对外暴露清理函数
return () => {
observer.disconnect();
clearInterval(interval);
};
}
const cleanup = setupWatermark('机密-张三-2026-07-23');
增加了三层防御: 1️⃣ MutationObserver 监听 style 属性变更 → 篡改后即时恢复 2️⃣ 定时轮询兜底 → 防 Observer 被断开 3️⃣ 水印恢复在同一个微任务/宏任务内 → 截图都来不及
方案三:CSS ::before/::after + Canvas Blob 的深层防御(高阶加固)
利用伪元素 content: url(...) 嵌入水印图片,配合 !important 和 pointer-events: none。
javascript
function createDeepWatermark(text) {
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 300;
const ctx = canvas.getContext('2d');
ctx.rotate(-20 * Math.PI / 180);
ctx.font = '16px Microsoft YaHei';
ctx.fillStyle = 'rgba(0, 0, 0, 0.06)';
ctx.fillText(text, 40, 160);
// 转为 Blob URL
const dataUrl = canvas.toDataURL();
// 注入一个带 !important 的样式,通过伪元素渲染
const style = document.createElement('style');
style.id = 'wm-core-style';
style.textContent = `
#app::before {
content: '' !important;
position: fixed !important;
inset: 0 !important;
background-image: url("${dataUrl}") !important;
background-repeat: repeat !important;
pointer-events: none !important;
z-index: 2147483647 !important;
opacity: 1 !important;
display: block !important;
visibility: visible !important;
}
`;
document.head.appendChild(style);
// 同样加上 MutationObserver 和定时器
// ...(复用方案二逻辑)
}
为什么要用 ::before 伪元素?
- DevTools 里无法直接「选中删除」伪元素
- 即使删掉
<style>标签轮询也能恢复 !important压过大部分用户注入的样式
方案四:防御 Degreed 用户(高阶反调试)
针对会打断点、手动删 Observer 的高级用户:
javascript
// 检测 DevTools 是否打开(简易版)
const devtools = /./;
devtools.toString = function() {
applyWatermark(); // 每次在 Console 求值时重建水印
return '';
};
// 重写 MutationObserver 的 disconnect 方法
const originalDisconnect = MutationObserver.prototype.disconnect;
MutationObserver.prototype.disconnect = function() {
// 判断是否是我们的 observer 被断开
if (this._isWatermarkObserver) {
console.warn('⚠️ 水印防护: 禁止断开 Observer');
return;
}
return originalDisconnect.call(this);
};
⚠️ 注意: 这种「反反调试」手段需要把握好度,过度反调试会影响正常开发体验,建议仅在内部敏感系统中使用。
要点总结
| 方案 | 防护等级 | 实现成本 | 用户能否破解 |
|---|---|---|---|
| CSS fixed 覆盖 | ⭐ | 5 分钟 | 1 秒 |
| Canvas 背景图 | ⭐⭐ | 15 分钟 | 需要改 CSS |
| Canvas + Observer | ⭐⭐⭐⭐ | 30 分钟 | 需要断点调试 |
| 深度防御全栈 | ⭐⭐⭐⭐⭐ | 1 小时 | 需要极高技术水平 |
实战建议:
- 普通后台管理系统:方案二(Canvas + Observer)就够用了,覆盖 95% 用户
- 高安全级别场景:方案三 + 方案四组合
- 永远别只依赖前端水印:前端水印是「防君子不防小人」的威慑手段,真正保密靠后端水印(接口注入 + 图片服务器打码)
- 截图的终极防御 :前端水印 +
Document.exitFullscreen()监听 + 页面失焦模糊处理 + 禁用右键/打印 - 性能注意 :Canvas 生成水印不要每次都
toDataURL,缓存一次结果;Observer 回调尽量轻量,避免高频触发
最后一句大实话: 用户真想扒你的数据,截图后裁剪掉水印发给 AI 擦除就行了。前端水印的核心价值是 「事后追溯」------每张泄露的截图上都有工号水印,安全部门就能找到「内鬼」。在 DOM 层面删掉水印只是「掩耳盗铃」,服务器端可以通过 HTTP 日志 + 请求指纹精确定位到人。