Featured image of post 用 CF Workers 加密文章

用 CF Workers 加密文章

查缺补漏

前言

之前在这篇文章里加入了给文章进行密码加密的功能。

但是不用脑子想都知道,这种加密方式只是掩耳盗铃图一乐。为什么呢?来看一下它的实现原理:

  1. 前端密码存储
1const correctPassword = {{ .Params.password }};

这里的密码是从 Hugo 的文章前置参数 (front matter) 中获取的,直接插入到 JavaScript 代码中。

  1. 用户输入密码
1const enteredPassword = prompt('请输入文章密码:');

使用浏览器内置的 prompt 弹窗,让用户输入密码。

  1. 密码校验
 1if (enteredPassword !== correctPassword) {
 2    alert('密码错误!');
 3    if (history.length <= 1) {
 4        window.opener = null;
 5        window.open('', '_self');
 6        window.close();
 7    } else {
 8        history.back();
 9    }
10}

如果用户输入的密码与前端存储的密码不一致,则提示密码错误,并尝试关闭页面或返回上一页。

所以,存在的问题与安全隐患也就很明显了:

1. 密码明文暴露问题

  • 由于 Hugo 是静态网站生成器,所有模板渲染后的内容都是静态 HTMLJavaScript 文件。因此,{{ .Params.password }} 会直接以明文形式暴露在生成的 HTML 源代码中。用户只需打开浏览器的“查看源代码”或开发者工具,即可轻松看到密码明文,完全失去了密码保护的意义。

2. 前端校验不安全

  • 纯前端的密码校验本质上是不安全的,因为前端代码完全暴露给用户,用户可以轻松绕过或修改校验逻辑。用户甚至可以直接在浏览器控制台中修改 JavaScript 变量,绕过密码检查。
  • 例如,用户可以在控制台中执行:
1enteredPassword = correctPassword; // 直接绕过密码检查

3. 用户体验问题(次要)

  • 使用浏览器原生的 prompt 弹窗,界面不美观,用户体验较差。

那么一般来讲,要怎么解决呢?

1. 后端校验

  • 真正安全的密码保护必须在后端实现。静态网站本身无法安全地实现密码保护功能。
  • 可以使用服务器端(如 Node.jsPHPPython 等)实现密码校验,只有密码正确时才返回文章内容。

2. 第三方服务或 API 校验

  • 如果一定要使用静态网站,可以考虑使用第三方服务(如 Netlify IdentityFirebase Authentication)实现安全的身份验证和密码保护。

3. 客户端加密(次优方案)

  • 如果一定要纯前端实现,可以考虑使用客户端加密方案(如 AES 加密),密码不直接暴露在源码中,而是存储加密后的内容,用户输入密码后再解密内容。
  • 但即使如此,密钥管理仍然是个问题,安全性仍然有限。

然而,Hugo 是静态网站生成器,生成的内容都是静态文件;Cloudflare Pages 作为一个静态网站托管服务,专门用于托管静态内容(HTMLCSSJS 等),并不支持动态后端代码(如 PHPNode.js 等)。


应用到 Cloudflare Pages 上面,这些加密手段如何呢?

一、后端校验(不可行)

  • 原因Cloudflare Pages 仅支持静态文件托管,不支持运行后端代码(如 Node.jsPHPPython 等)。
  • 结论:无法直接在 Cloudflare Pages 上实现后端密码校验。

二、第三方服务或 API 校验(推荐,可行)

虽然 Cloudflare Pages 本身不支持后端代码,但你可以借助第三方服务实现安全的密码保护:


实现思路

  • 使用第三方身份验证服务(如 Firebase AuthenticationAuth0Netlify Identity 等)实现用户登录和密码保护。
  • 用户访问页面时,前端调用第三方服务的 API 进行身份验证,验证通过后再加载受保护的内容。
  • 受保护的内容可以存储在 Cloudflare Pages 上,但内容本身是加密的,只有通过第三方服务验证后才能解密显示。

优点

  • 安全性高,密码不会暴露在前端代码中。
  • 用户体验较好,支持多种登录方式(邮箱、Google、GitHub 等)。

缺点

  • 需要额外的第三方服务,增加了一定的复杂性。

三、客户端加密方案(可行,但安全性有限)

实现思路

  • 使用 AES 等对称加密算法,在发布文章前对文章内容进行加密。
  • 加密后的内容存储在 Hugo 生成的静态页面中。
  • 用户访问页面时,输入密码后,前端 JavaScript 使用用户输入的密码尝试解密内容。
  • 如果密码正确,内容解密成功并显示;密码错误则无法解密,显示错误提示。

具体实现步骤

  1. 在本地使用 AES 加密工具(如 CryptoJS)对文章内容进行加密。
  2. 将加密后的内容放入 Hugo 的 Markdown 文件或模板中。
  3. 在 Hugo 模板中引入 CryptoJS 库,用户输入密码后尝试解密内容。

示例代码(简化版)

 1<script src="https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.min.js"></script>
 2<script>
 3  const encryptedContent = "{{ .Params.encryptedContent }}"; // 加密后的内容
 4  const password = prompt("请输入密码:");
 5  try {
 6    const bytes = CryptoJS.AES.decrypt(encryptedContent, password);
 7    const originalText = bytes.toString(CryptoJS.enc.Utf8);
 8    if (!originalText) throw new Error("解密失败");
 9    document.getElementById("content").innerHTML = originalText;
10  } catch (e) {
11    alert("密码错误或解密失败!");
12  }
13</script>
14<div id="content"></div>

优点

  • 不需要额外的第三方服务,纯静态实现。
  • 密码不会直接暴露在源码中(但加密后的内容仍然暴露)。

缺点

  • 安全性有限,攻击者仍然可以暴力破解或尝试解密。
  • 用户体验一般,且每次修改内容都需要重新加密。

Hugo Encryptor 加密

还有这个Hugo的加密项目:

Hugo-Encryptor 是一款能够帮助作者保护文章内容的工具。它使用 AES-256 来对文章的内容进行加密,并且通过在文章中嵌入内联 JavaScript 代码来验证读者输入的密码是否正确。没有正确的文章密码,读者将无法看到文章的加密内容。
Python

它的实现原理是:

  • Hugo 构建阶段(本地构建时),使用 AES 算法对文章内容进行加密。
  • 加密后的内容以密文形式存储在生成的静态 HTML 文件中。
  • 用户访问页面时,前端 JavaScript 提示用户输入密码,输入密码后尝试解密内容。
  • 如果密码正确,内容解密成功并显示;密码错误则无法解密,提示错误。

这种方式属于我们之前提到的客户端 AES 加密方案

由于 Cloudflare Pages 是纯静态托管服务,hugo_encryptor 生成的内容也是纯静态的 HTMLJavaScript 文件,完全适合 Cloudflare Pages。而且 hugo_encryptor 不需要任何后端代码或动态服务,完全依靠客户端 JavaScript 解密,理论上是适合 Cloudflare Pages 这种静态托管环境的。

缺点也很明显:

  • 密文内容仍然暴露在前端,理论上存在暴力破解的可能性(但 AES 算法本身安全性较高,暴力破解难度较大)。
  • 密码管理和更改内容时需要重新加密,稍显麻烦。

主要是每次修改提交都要加密一次,实在是有点不胜其烦,于是不想用这个方法。

Workers 加密

所以,还有什么方法吗?那当然是继续薅赛博佛祖的羊毛啦!用万能的 Cloudflare Workers 啦!

新建一个 Workers,然后替换成以下代码:

  1// 内存中存储随机令牌与路径的映射关系
  2const tokenStore = new Map();
  3
  4async function handleRequest(request, env) {
  5  const url = new URL(request.url);
  6  const pathname = url.pathname;
  7
  8  // 强制 HTTPS 协议访问
  9  if (url.protocol !== "https:") {
 10    url.protocol = "https:";
 11    return Response.redirect(url.toString(), 301);
 12  }
 13
 14  // 配置受保护的文章
 15  const ARTICLES = {
 16    "/post/your-private-post/": { // 你要加密的文章URL
 17      password: env.PRIVATE_POST_PASSWORD, // 环境变量名称
 18      authType: "permanent", // 永久授权
 19    },
 20    "/post/your-secret-post/": { // 你要加密的文章URL
 21      password: env.SECRET_POST_PASSWORD, // 环境变量名称
 22      authType: "temporary", // 临时授权
 23    },
 24  };
 25
 26  const articleConfig = ARTICLES[pathname];
 27  if (!articleConfig) {
 28    return fetch(request); // 如果路径未配置保护,直接返回原始请求
 29  }
 30
 31  const { password: correctPassword, authType } = articleConfig;
 32
 33  // 检查 Cookie 中的随机令牌
 34  const cookie = request.headers.get("Cookie") || "";
 35  const cookies = Object.fromEntries(cookie.split("; ").map((c) => c.split("=")));
 36  const token = cookies[`auth_${pathname}`];
 37
 38  if (token && tokenStore.get(token) === pathname) {
 39    // 如果令牌有效,返回原始请求
 40    return fetch(request);
 41  }
 42
 43  // 如果是 POST 请求,验证密码
 44  if (request.method === "POST") {
 45    const formData = await request.formData();
 46    const enteredPassword = formData.get("password");
 47
 48    if (enteredPassword === correctPassword) {
 49      // 密码正确,生成随机令牌
 50      const randomToken = crypto.randomUUID();
 51      tokenStore.set(randomToken, pathname); // 将令牌与路径映射存储
 52
 53      // 设置 Cookie
 54      const headers = {
 55        Location: pathname,
 56      };
 57      if (authType === "permanent") {
 58        // 永久授权:Cookie 有效期 1 年
 59        headers["Set-Cookie"] = `auth_${pathname}=${randomToken}; Path=${pathname}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000`;
 60      } else if (authType === "temporary") {
 61        // 临时授权:Cookie 有效期 1 分钟
 62        headers["Set-Cookie"] = `auth_${pathname}=${randomToken}; Path=${pathname}; HttpOnly; Secure; SameSite=Lax; Max-Age=1`;
 63      }
 64
 65      return new Response(null, {
 66        status: 302,
 67        headers,
 68      });
 69    } else {
 70      // 密码错误,返回密码输入页面
 71      return new Response(passwordPage(pathname, true), {
 72        headers: { "Content-Type": "text/html;charset=UTF-8" },
 73      });
 74    }
 75  }
 76
 77  // 默认返回密码输入页面
 78  return new Response(passwordPage(pathname), {
 79    headers: { "Content-Type": "text/html;charset=UTF-8" },
 80  });
 81}
 82
 83function passwordPage(pathname, error = false) {
 84  return `
 85  <!DOCTYPE html>
 86  <html lang="zh">
 87  <head>
 88    <meta charset="UTF-8">
 89    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 90    <title>请输入密码</title>
 91    <style>
 92      body {
 93        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 94        background-image: url('your img url'); //你的验证页面背景图URL
 95        background-size: cover;
 96        background-position: center;
 97        background-repeat: no-repeat;
 98        display: flex;
 99        justify-content: center;
100        align-items: center;
101        height: 100vh;
102        margin: 0;
103        padding: 0;
104      }
105      body::before {
106        content: '';
107        position: fixed;
108        top: 0;
109        left: 0;
110        width: 100%;
111        height: 100%;
112        z-index: -1;
113        backdrop-filter: blur(2px);
114        background-color: rgba(255, 255, 255, 0.7);
115      }
116      .container {
117        background-color: rgba(255, 255, 255, 0.9);
118        padding: 30px;
119        border-radius: 10px;
120        box-shadow: 0 4px 10px rgba(0,0,0,0.3);
121        text-align: center;
122        width: 90%;
123        max-width: 320px;
124        margin: 15px;
125      }
126      h3 {
127        margin-bottom: 20px;
128        color: #333;
129        font-size: 18px;
130      }
131      input[type="password"] {
132        width: 90%;
133        padding: 12px;
134        margin-bottom: 15px;
135        border-radius: 5px;
136        border: 1px solid #ccc;
137        font-size: 16px;
138      }
139      button {
140        padding: 12px 24px;
141        background-color: #4CAF50;
142        color: white;
143        border: none;
144        border-radius: 5px;
145        cursor: pointer;
146        font-size: 16px;
147        width: 100%;
148      }
149      button:hover {
150        background-color: #45a049;
151      }
152      .error {
153        color: red;
154        margin-bottom: 15px;
155        font-size: 14px;
156      }
157    </style>
158  </head>
159  <body>
160    <div class="container">
161      <h3>此文章受密码保护,请输入密码:</h3>
162      ${error ? "<p class='error'>密码错误,请重试!</p>" : ""}
163      <form method="POST">
164        <input type="password" name="password" required placeholder="请输入密码" />
165        <button type="submit">提交</button>
166      </form>
167    </div>
168  </body>
169  </html>
170  `;
171}
172
173export default {
174  async fetch(request, env) {
175    return handleRequest(request, env);
176  },
177};

实现原理

实现原理:

  1. 身份验证流程:
  • 用户访问受保护的文章路径
  • 系统检查用户是否有有效的认证令牌(Cookie)
  • 如果没有有效令牌,显示密码输入页面
  • 用户输入密码后提交,系统验证密码
  • 密码验证成功后,生成随机令牌并设置对应的 Cookie
  • 最后重定向用户到原始文章页面
  1. 令牌管理:
  • 使用内存中的 Map(tokenStore) 存储令牌与路径的映射
  • 支持两种授权类型:永久授权(Cookie 有效期1年)和临时授权(Cookie 有效期1分钟)

使用方法

部署好 Workers 后在 设置 -> 变量和机密 点击 添加

变量名称 一定要大写,对应的是 Workers 里的 PRIVATE_POST_PASSWORD 或者 SECRET_POST_PASSWORD(如 TEST),值就是你要加密文章所要设定的密码(如 password)。

变量名称一定要大写!变量名称一定要大写!变量名称一定要大写!

然后同样是在设置里,选择第一项 域和路由,添加 路由区域 选你的主域名,路由填你要加密的文章路径,就完成咯!

举例与测试

以我测试的两篇文章为例:

  1. 首先创建了两篇测试文章,一篇用于永久验证的URL是 /post/cf-auth,另一篇用于单次验证的URL是 /post/cf-pwd。然后在 Workers 里修改参数:
 1// 配置受保护的文章
 2const ARTICLES = {
 3  "/post/cf-auth/": { // 你要加密的文章URL
 4    password: env.AUTH_PWD, // 环境变量名称
 5    authType: "permanent", // 永久授权
 6  },
 7  "/post/cf-pwd/": { // 你要加密的文章URL
 8    password: env.TEST_PWD, // 环境变量名称
 9    authType: "temporary", // 临时授权
10  },
11};
  1. 修改完部署后去配置环境变量:

  1. 添加路由

区域选择你的博客域名,我这里是 iftcblog.me。然后路由要填你要加密的文章路径,但是前面不要加 https://,也不要把后面的星号 * 忘了。

前面不要加 https://!记得后面要加上星号 *

然后访问永久验证文章短暂验证文章,就搞定啦!(密码已经写在上面了哦)

小结

当然还是有潜在的安全问题,如 CSRF 保护和适当的令牌存储机制等等。但是我又不是专业的,感觉做到这个程度已经差不太多了。顺便贴一篇查到的关于 cookies 的文章:Cookie 的 SameSite 属性 | 阮一峰的网络日志
至于页面的美化么,我又完全不懂前端。不想折腾了,先这么将就着吧,等日后有好的想法再说。

Licensed under CC BY-NC-SA 4.0
最后更新于 2025-09-16 11:05 +0800
给博主施舍一个赞吧(;へ:) ❤️