给静态博客的敏感内容加一道像素马赛克门禁

起因:渗透测试发现文章泄露了完整架构

前几天给自己的博客做了一次渗透测试,报告里有一条让我后背发凉:那篇《用 Cloudflare Tunnel 在端口被封的 VPS 上搭建代理》的文章,把整套基础设施的架构蓝图写得太清楚了——隧道名、ingress 路径、SSH 子域和端口、凭证文件路径、订阅地址,全在明文里。

任何一个读者打开那篇文章,配合右键查看源码,就能拿到一份现成的攻击面清单。

但问题来了:这篇文章又不能直接删。它是我折腾 VPS 的完整记录,技术原理部分(Tunnel 反转连接方向、为什么 522)是有价值的教程。我要做的是把敏感参数藏起来,但保留教程的可读性

核心矛盾:静态站没有后端

第一反应是给敏感文字加点"打码"效果——CSS 模糊、黑色色块覆盖之类的。但我很快就意识到一个致命问题:

博客是纯静态的。 不管前端怎么遮挡,敏感文字只要写进 HTML,右键查看源码就能一字不漏地读到。

前端打码只是视觉欺骗,不是信息保护。对渗透测试报告来说,这根本不算修复。

真正的修复只有一条路:敏感值不能出现在静态 HTML 里,得在服务端鉴权通过后才下发。但 Hugo + Cloudflare Pages 是静态站,哪来的服务端?

方案选型

Cloudflare Pages 有个 Pages Functions,可以在边缘跑一段 JS 处理请求。于是思路就清晰了:

环节做法
构建产物敏感处只渲染一个带 data-key 的占位容器,无明文
真实值存储作为 Cloudflare 密钥存在边缘,不入仓库
边缘鉴权Pages Function 校验密码,签发 HttpOnly 签名 cookie
前端展示未认证显示像素马赛克;点击输密码;认证后自动还原

关键点在于:敏感值完全不进静态 HTML。所以「查看源码」什么都看不到,这才是真正的信息泄露修复。

后来我纠结了一下马赛克的视觉效果,最终选了 canvas 像素马赛克而不是 CSS 模糊——我想要的是"真实的视觉马赛克",不是那种一眼就能看出是 blur 滤镜的糊团子。

鉴权流程

设计成两条路径,对应两种访问场景:

首次访问(未认证)

  1. 页面加载,GET /api/reveal → 401
  2. 敏感处渲染 canvas 像素马赛克
  3. 点击马赛克 → 弹密码框
  4. POST /api/reveal 校验密码
  5. 正确 → 签发 HttpOnly 签名 cookie + 返回真实值 → 前端替换马赛克

再次访问(cookie 有效)

  1. 页面加载,GET /api/reveal 带 cookie → 200 返回真实值
  2. 前端直接还原,不用再输密码

cookie 用 HMAC 签名,格式 过期时间戳.签名,验证时重算 HMAC 常时比较。密码校验也是常时比较,防时序攻击。HttpOnly 防 JS 读取,Secure + SameSite=Lax 防泄露。

实现要点

构建侧:shortcode 占位

我写了一个 Hugo shortcode,文章里把敏感值替换成占位调用。构建出来的 HTML 只有一个空的 <span class="redact" data-key="...">,里面没有任何文字。真实值存在 REVEAL_VALUES 这个 Pages 密钥里,是个 JSON,键名对上 data-key

前端侧:canvas 像素马赛克

马赛克是我用 canvas 画的:离屏画一个超低分辨率的画布(每个像素 = 一个马赛克块),随机填灰白色像素,再用 imageSmoothingEnabled = false 放大到可见 canvas,得到块状像素打码的效果。比 filter: blur 真实得多,像老电视雪花屏。

边缘侧:Pages Function

一个 functions/api/reveal.js,处理 GET(凭 cookie 还原)和 POST(校验密码)。我设了三个密钥都不入仓库:访问密码、签名密钥、真实值映射。

踩过的坑

坑1:马赛克垂直偏上

canvas 高度定的是 18px,但正文行高 1.85,基线对齐时马赛克整体偏上。而且 .redact 用了 inline-flex,flex 容器里 canvas 的 vertical-align: middle 不生效。

解法:canvas 高度加到 24px(4 个 6px 块),.redact 改回 inline-block 让它参与行内基线对齐,canvas 用 vertical-align: middle + top: -1px 微调。

坑2:马赛克颜色偏紫

最初我随手写的配色是 rgba(r, g-10, b+40),蓝色通道比红绿高 50,整体偏冷紫。我要的是灰白雪花屏效果。

解法:改成纯灰阶 rgba(g, g, g),R=G=B,范围 150-239,不透明度 0.7-1.0。灰白打码观感。

坑3:马赛克右侧的绿色小尾巴

这个坑最隐蔽。马赛克右边总冒出一小截绿色,我一开始以为是 canvas 边缘像素没填满,折腾半天宽度对齐、整除都没用。

后来我意识到:绿色是站点的 accent 色。那个绿色小尾巴根本不是 canvas,是 .redact-revealed(解密后显示真实值的 span)的绿色虚线边框露出来了。

虽然 JS 设了 span.hidden = true,但 CSS 里 .redact-revealed { display: inline-block } 的显式声明优先级高于浏览器默认的 [hidden] { display: none }。结果隐藏的 span 仍以 inline-block 渲染,露出边框和文字。

解法:加一条 .redact-revealed[hidden] { display: none !important },强制压过 display 声明。

教训:CSS 显式 display 会覆盖 [hidden] 属性,这是一个很容易踩的坑。凡是给带 hidden 的元素写过 display 规则的,都要补一条 [hidden] 的强制隐藏。

坑4:canvas 边缘半块拉伸

canvas 内部尺寸 ceil(w * dpr) 不一定能被像素块边长整除,导致离屏画布最后一列被拉伸,右边缘出现毛刺。

解法:内部尺寸严格按 colsCss * block * dpr 计算,先定列数再反推宽度,保证离屏画布与可见画布严格整除,每个块放大整数倍,无拉伸。

最终效果

文章里现在长这样——下面这几个块就是真实可交互的,点一下试试:

要查看的密钥:

再点这个:

未认证时是灰白像素马赛克;点击弹密码框;输入正确的访问密码后,边缘 Function 签发 cookie 并返回真实值,马赛克替换成绿色虚线框包裹的明文;下次再访问,凭 cookie 自动还原,不用重复输密码。

如果你看到的还是马赛克,说明还没输过密码——点一下试试。

安全性评估

这套方案对我的静态站来说,防护是实打实的:

攻击面防护
查看源码❌ 无明文,只有占位容器
直接读 HTML❌ 敏感值不进静态产物
暴力破解密码⚠️ 依赖密码强度(这是唯一弱点)
伪造 cookie❌ HMAC 签名,需签名密钥
XSS 偷 cookie❌ HttpOnly,JS 读不到
时序攻击❌ 密码和签名都常时比较

唯一的弱点是密码本身。所以密码要够强,而且这个门禁保护的是"不想被随便看到"的内容,不是"绝对不能泄露"的机密——后者我根本不会写进博客。

教训总结

  1. 静态站的"打码"如果只在前端,等于没打——明文在 HTML 里就等于公开
  2. 真实保护需要服务端鉴权,Pages Functions 让静态站也能有后端逻辑
  3. CSS 的 display 会覆盖 [hidden],这是绿色小尾巴的根因,凡写过 display 的隐藏元素都要补强制规则
  4. canvas 尺寸要整除像素块,否则边缘半块拉伸出毛刺
  5. 像素马赛克比 CSS blur 真实,离屏低分画布 + imageSmoothingEnabled=false 放大是关键
  6. 密钥不入仓库,真实值放 Cloudflare 密钥,仓库里只有占位 shortcode

整套东西从发现问题到上线,我花了大半天搞定。现在那篇 Tunnel 文章的敏感参数都藏起来了,教程原理部分照常可读,想看具体参数得知道密码。对一个个人博客来说,这层防护我觉得够用了。

HugoCloudflare前端安全Canvas