基于腾讯的EDGEONE的边缘函数实现POW工作量证明用于网站功能防刷

前言

作为一个个人站站长,经常碰到的问题就是服务器被人恶意刷了。不仅仅是图片、文件被刷流量,还有接口功能被人给刷了,尤其是涉及到数据库的一些接口、功能(比如搜索功能等)。相比起被刷流量损失宽带,接口功能被刷则会导致服务器的CPU、内存资源被大量消耗,引发服务器卡顿 甚至可能(大概率)会导致服务器宕机,业务中断。

为了应付这种方法,我们常用的方法一般有下面几种

1、敏感接口或者页面使用人机验证功能。常用的人机验证有google的recaptcha 还有阿里腾讯等等的服务。有的人机验证是让我们点击图片,符合要求才过关,用户体验十分不好,并且现在已经有专门绕过的方案了。有的人机验证则涉及到很多信息的收集,不太可控,并且对隐私不友好。而且,最为重要的一点,就是有的(如GOOGLE的)人机验证的免费额度太少了,用完后后续的费用很贵,而且不续费的网站就无法正常工作。

2、使用单IP流量控制。比如说如果某一个IP,在短时间内大量请求登录接口,那就对这个IP/IP段执行封禁措施。但是这个方案缺点也不少,比如说如果恶意刷接口的人并不只拥有一个IP 那么这种限制IP的方案效果就不太好了。

关于我的这个方案

首先得说到这个方案是怎么来的,最近一段时间,腾讯开启了Edge One免费送无限量套餐的活动[1],我也被吸引进去了(虽然本博客用的不是免费套餐),其中就包含了一些基础的安全防御功能,以及一定的边缘函数的配额。但是EdgeOne提供的安全防御功能对于免费版、个人版本来说,存在可用防火墙规则少,无法进行规则细化的问题,并且大部分规则处置方法简单,无法设置为人机验证(JavaScript挑战)来阻挡攻击,如果我们想要更为细化访问规则,得升级套餐[2]

结合上述条件,我就想出了一种方案,通过使用EdgeOne的边缘函数的功能,实现一个Pow挑战。

当然,这里解释下什么是Pow。Pow(Proof of Work),工作量证明,一开始是出现在区块链(关于Pow与区块链我们这里不解释,请自行搜索),后面也用于防止恶意自动化请求。服务器要求客户端发送请求之前需要完成一定量的计算工作,只有提供正确的工作证明,网站才会继续处理请求(服务器验算操作比较简单),这样子攻击者的成本会非常非常的高,尤其是大规模攻击的时候。

回到正题,我可以对边缘函数设定触发规则,当满足了指定的规则(比如说一些敏感的路径如wordpress的搜索功能),边缘函数会被触发(当然,请求此时被边缘函数接管了),然后我预设的边缘函数向客户端发起一个挑战,客户端需要完成边缘函数发起的挑战,请求才会回源。否则该请求就在边缘函数处就卡住了,根本不会返回到源站。

这样子操作的优点如下:

1、无需对源站的任何代码修改。这也是最为重要的一点,因为验证功能是在腾讯的CDN(边缘处)进行的,对源站压力不大。

2、用户友好。用户不需要使劲完成认证,只需要点开,等待电脑/设备完成挑战,不需要使劲点什么烟囱、消火栓之类的。

3、成本低,我这种思路主要资源使用的是腾讯的EdgeOne的边缘函数功能,永久免费版本套餐都有300万次请求以及300万毫秒的CPU运行时长配额,足够用很长一段时间了。

思路

思路大概是这个样子的。

当我们在EdgeOne配置好对应路径的边缘函数规则后,我后文给出的代码会实现如下的功能。

0、(一切开始之前)腾讯的EdgeOne检查每个请求是否匹配边缘函数的触发规则,如果没触发,那么执行返回缓存或者回源(注:回源 当客户端请求资源时,如果CDN节点上未缓存该资源,CDN节点会回源站获取资源。)等,如果匹配了边缘函数的触发规则,则触发边缘函数。

1、在边缘函数侧(非用户端):触发了边缘函数之后,边缘函数读取出客户端的IP和User-Agent,并检查请求中是否有通过验证的Cookie,如果Cookie通过签名校验且信息匹配,那么放行流量进行回源(使用Passthrough方案[3]

2、如果验证未通过的话,边缘函数侧生成挑战前缀,前缀为当前时间戳+IP+UA一起生成的一段文本,并将前缀返回给客户端。

3、客户端获得到前缀开始计算(这个阶段就是PoW),这个计算过程是:找出某一个值 不断尝试与前缀拼接,如果拼接起来的值HASH过后前几个字符(这个数量可以在环境变量中定义,默认是4个)全为0,那么即为挑战成功,将结果发送给边缘函数侧。

4、边缘函数侧获得到前缀以及挑战结果,检查前缀是否合格(是否超出有效期防止重放攻击,是否被伪造或者被复制,即IP、UA是否匹配),然后检查挑战是否成功(验算结果),如果成功,则根据当前时间、IP、UA生成一个放行cookie给客户端

5、客户端获得到放行cookie 并自动刷新界面重新请求,下次请求浏览器带上这个cookie给边缘函数,直到认证cookie过期。边缘函数则会对请求进行放行。

流程图如下(图1):

图1:关于我这个实现的思路的流程图

代码

所以,说完思路 下面就是提供的代码。你可以按需修改,但是请不要删除代码中的关于我作者信息的注释

/**
 * edgeone-pow.js ― PoW + 绑定 IP&UA POW验证
 * see www.q2019.com
 * 作者 q2019715@q2019.com
 * 适用于 Tencent Cloud EdgeOne 边缘函数
 */

// ──────────────────── 常量配置 ────────────────────
const COOKIE_NAME         = 'q2019_pow_ok';
const TTL_SECONDS         = parseInt(env.TTL_SECONDS) || 5;      // Cookie 免打扰窗口,一次验证后多少秒内无需验证
const DIFFICULTY          = parseInt(env.DIFFICULTY) || 4;      // 控制难度,越大越难
const SECRET_KEY          = env.SECRET_KEY || (() => { throw new Error('SECRET_KEY required') })(); // 非常重要,秘钥
const PREFIX_TTL_SECONDS  = parseInt(env.PREFIX_TTL_SECONDS) || 300; // 前缀有效期(秒),挑战超时作废
const SIG_HEX_LEN         = 32;   // 前缀签名截断长度 (32 hex = 128-bit)
const MAX_PREFIX_LEN      = 64;   // 前缀最大长度保护,防止生成的prefix过长导致运算成本激增,超过直接报错
const MAX_FUTURE_SECONDS  = 60;   // 防止重放攻击 时间最多提前60S
const MIN_PAST_SECONDS    = 60;   // 防止重放攻击 时间最多晚60S

// 验证环境变量
if (DIFFICULTY < 1 || DIFFICULTY > 8) {
  throw new Error('DIFFICULTY must be between 1-8');
}

addEventListener('fetch', e => e.respondWith(wrapErrors(e)));

async function wrapErrors(event) {
  try {
    return await handle(event.request);
  } catch (err) {
    console.error('Error:', err);
    return jsonError(err.message || 'internal error', 500);
  }
}

async function handle(request) {
  const clientIp   = (request.eo && request.eo.clientIp) || '';
  const userAgent  = request.headers.get('User-Agent') || '';
  const cookies    = parseCookies(request.headers.get('cookie') || '');

  /* A. 已有 Cookie 且签名匹配 → 直接回源 */
  if (await cookieValid(cookies[COOKIE_NAME], clientIp, userAgent)) {
    return fetch(request);
  }

  /* B. 浏览器 POST 过来的 PoW 结果 */
  const prefix = request.headers.get('x-pow-prefix');
  const nonce  = request.headers.get('x-pow-nonce');

  if (request.method === 'POST' && prefix && nonce) {
    if (await verifyPow(prefix, nonce, clientIp, userAgent)) {
      const ts  = Math.floor(Date.now() / 1000);
      const sig = await sha256Hex(SECRET_KEY + ts + clientIp + userAgent);

      const resp = new Response(JSON.stringify({ success: true }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' }
      });
      resp.headers.set(
        'Set-Cookie',
        `${COOKIE_NAME}=${ts}:${sig}; Max-Age=${TTL_SECONDS}; Path=/; SameSite=Lax; HttpOnly; Secure`
      );
      return resp;
    }
    return jsonError('invalid proof', 403);
  }

  /* C. 首次访问 / 校验失败 → 下发新的挑战页面 */
  const signedPrefix = await buildSignedPrefix(clientIp, userAgent);
  return new Response(buildPowPage(signedPrefix, DIFFICULTY, PREFIX_TTL_SECONDS), {
    headers: { 'Content-Type': 'text/html;charset=UTF-8' },
  });
}

/* ──────────────────── 辅助函数 ──────────────────── */
function parseCookies(str) {
  return str.split(/;\s*/).filter(Boolean).reduce((o, s) => {
    const [k, ...vs] = s.split('=');
    try {
      o[decodeURIComponent(k)] = decodeURIComponent(vs.join('='));
    } catch (e) {}
    return o;
  }, {});
}

async function sha256Hex(text) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}

function isTimestampReasonable(ts) {
  if (!Number.isInteger(ts) || ts < 0) return false;
  const now = Math.floor(Date.now() / 1000);
  return ts <= now + MAX_FUTURE_SECONDS && ts >= now - MIN_PAST_SECONDS;
}

async function buildSignedPrefix(ip, ua) {
  const ts  = Math.floor(Date.now() / 1000);
  const sig = await sha256Hex(SECRET_KEY + ts + ip + ua);
  return ts.toString(16) + ':' + sig.slice(0, SIG_HEX_LEN);
}

async function verifyPow(prefix, nonce, ip, ua) {
  if (prefix.length > MAX_PREFIX_LEN) return false;
  if (!/^[0-9a-f]+:[0-9a-f]{32}$/.test(prefix)) return false;
  if (!(await sha256Hex(prefix + nonce)).startsWith('0'.repeat(DIFFICULTY))) return false;

  const [tsHex, sigPart] = prefix.split(':');
  const ts = parseInt(tsHex, 16);
  if (!isTimestampReasonable(ts)) return false;

  const expectSig = (await sha256Hex(SECRET_KEY + ts + ip + ua)).slice(0, SIG_HEX_LEN);
  if (sigPart !== expectSig) return false;
  if (Math.floor(Date.now() / 1000) - ts > PREFIX_TTL_SECONDS) return false;

  return true;
}

async function cookieValid(val, ip, ua) {
  if (!val || typeof val !== 'string') return false;
  const parts = val.split(':');
  if (parts.length !== 2) return false;
  const [tsStr, sig] = parts;
  const ts = Number(tsStr);
  if (!isTimestampReasonable(ts)) return false;
  if (Math.floor(Date.now() / 1000) - ts > TTL_SECONDS) return false;
  const expect = await sha256Hex(SECRET_KEY + ts + ip + ua);
  return sig === expect;
}

function jsonError(msg, code) {
  return new Response(JSON.stringify({ error: msg }), {
    status: code,
    headers: { 'Content-Type': 'application/json;charset=UTF-8' },
  });
}

function buildPowPage(prefix, difficulty, prefixTtl) {
  return `<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>服务器安全验证</title>
  <style>
    :root {
      --primary-color: #4f46e5;
      --primary-dark: #3730a3;
      --bg-light: #f8fafc;
      --bg-dark: #0f172a;
      --text-light: #1e293b;
      --text-dark: #e2e8f0;
      --card-light: #ffffff;
      --card-dark: #1e293b;
      --border-light: #e2e8f0;
      --border-dark: #334155;
      --warning: #f59e0b;
    }
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
      background: var(--bg-light);
      color: var(--text-light);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.3s ease;
    }
    @media (prefers-color-scheme: dark) {
      body { background: var(--bg-dark); color: var(--text-dark); }
      .card { background: var(--card-dark) !important; border-color: var(--border-dark) !important; }
      .progress-bg { background-color: var(--border-dark) !important; }
    }
    .container {
      width: 100%; max-width: 480px; margin: 0 auto; padding: 20px;
    }
    .card {
      background: var(--card-light);
      border-radius: 16px;
      padding: 32px;
      box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
      border: 1px solid var(--border-light);
      text-align: center;
    }
    .icon {
      width: 64px; height: 64px; margin: 0 auto 24px;
      border-radius: 50%;
      background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
      display: flex; align-items: center; justify-content: center;
      animation: pulse 2s infinite;
    }
    .icon svg { width: 32px; height: 32px; fill: white; }
    @keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }
    .title { font-size: 24px; font-weight: 700; margin-bottom: 12px; }
    .subtitle { font-size: 16px; opacity: 0.8; margin-bottom: 32px; line-height: 1.5; }
    .progress-container { margin-bottom: 24px; }
    .progress-bg {
      width: 100%; height: 8px; background-color: var(--border-light);
      border-radius: 4px; overflow: hidden; margin-bottom: 16px;
    }
    .progress-bar {
      height: 100%; background: linear-gradient(90deg, var(--primary-color), var(--primary-dark));
      border-radius: 4px; transition: width 0.3s ease; width: 0%;
    }
    .loading-dots { display: inline-flex; gap: 4px; }
    .loading-dots span {
      width: 6px; height: 6px; background: var(--primary-color);
      border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both;
    }
    .loading-dots span:nth-child(1) { animation-delay: -0.32s; }
    .loading-dots span:nth-child(2) { animation-delay: -0.16s; }
    @keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
    .footer { margin-top: 24px; font-size: 14px; opacity: 0.6; line-height: 1.4; }
    .noscript {
      background: var(--warning); color: white; padding: 16px;
      border-radius: 8px; margin-bottom: 24px; font-weight: 600;
    }
    @media (max-width: 640px) {
      .container { padding: 16px; }
      .card { padding: 24px; }
      .title { font-size: 20px; }
      .subtitle { font-size: 14px; }
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="card">
      <div class="icon">
        <svg viewBox="0 0 24 24"><path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9L10,17Z"/></svg>
      </div>
      <h1 class="title">稍等,安全验证中</h1>
      <p class="subtitle">我们正在验证您的访问请求,请稍后</p>
      <noscript>
        <div class="noscript">⚠️ 请启用 JavaScript 以继续访问本网站</div>
      </noscript>
      <div class="progress-container">
        <div class="progress-bg"><div class="progress-bar" id="progressBar"></div></div>
        <div id="statusText">正在初始化<span class="loading-dots"><span></span><span></span><span></span></span></div>
      </div>
      <div class="footer">此过程通常需要几秒钟完成,请耐心等待<br><small>Powered by Q2019.COM</small></div>
    </div>
  </div>
  <script>
    (function() {
      'use strict';
      const prefix = "${prefix}";
      const difficulty = ${difficulty};
      const prefixTtl = ${prefixTtl}; // 有效期秒数
      const target = "0".repeat(difficulty);

      // 解析前缀时间戳(十六进制)
      const prefixTs = parseInt(prefix.split(':')[0], 16);

      let attempts = 0;
      const progressBar = document.getElementById('progressBar');
      const statusText = document.getElementById('statusText');

      if (!window.crypto || !window.crypto.subtle) {
        statusText.innerHTML = '❌ 浏览器不支持必要的加密功能';
        return;
      }

      function showExpiredAndReload() {
        statusText.innerHTML = '❌ 出现了小问题,将会在3秒后重试';
        setTimeout(() => location.reload(), 3000);
      }

      async function sha256Hex(text) {
        const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
        return Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join('');
      }

      async function updateProgress() {
        const expectedAttempts = Math.pow(16, difficulty);
        const progress = Math.min(90, (attempts / expectedAttempts) * 100);
        progressBar.style.width = progress + '%';
        statusText.innerHTML = '正在完成安全验证中...请稍等...';
      }

      async function solvePow() {
        statusText.innerHTML = '开始工作量证明计算...';
        let nonce = 0;
        const batchSize = 1000;

        while (true) {
          // 检测前缀是否已过期
          const now = Math.floor(Date.now() / 1000);
          if (now - prefixTs > prefixTtl) {
            showExpiredAndReload();
            return;
          }

          for (let i = 0; i < batchSize; i++) {
            attempts++;
            const hash = await sha256Hex(prefix + nonce);
            if (hash.startsWith(target)) {
              progressBar.style.width = '100%';
              statusText.innerHTML = '✅ 验证成功,正在提交...';
              try {
                const response = await fetch(location.href, {
                  method: 'POST',
                  headers: {
                    'X-PoW-Prefix': prefix,
                    'X-PoW-Nonce': nonce.toString()
                  }
                });
                if (response.ok) {
                  statusText.innerHTML = '🎉 验证完成,正在跳转...';
                  setTimeout(() => location.reload(), 1000);
                } else {
                  throw new Error('验证失败');
                }
              } catch (err) {
                statusText.innerHTML = '❌ 提交失败,请刷新重试';
              }
              return;
            }
            nonce++;
          }
          await updateProgress();
          await new Promise(res => setTimeout(res, 0));
        }
      }

      solvePow().catch(err => {
        console.error('PoW error:', err);
        statusText.innerHTML = '❌ 计算出错,请刷新重试';
      });
    })();
  </script>
</body>
</html>`;
}

部署方法

前提条件

首先一切的前提是开通了腾讯的EdgeOne并且绑定好了站点

创建边缘函数

在EdgeOne控制台(传送门:https://console.cloud.tencent.com/edgeone/zones/detail)中,点击服务总览,选中站点后点击边缘函数、点击函数管理,然后点击新建函数,如图2

图2 EdgeOne创建边缘函数的指引

然后点击创建Hello World ,如图3,并点击下一步

图3 EdgeOne 选择边缘函数的创建模版

然后回跳转到函数建立,如图4,名称请自己建立一个,函数代码拷贝刚刚我提供的代码

图4 EdgeOne添加边缘函数的代码的截图

然后点击创建并部署

完成部署后,回到函数管理界面,点击对应的函数名称,进入编辑页面,比如图5中红框框圈出来的函数名称

图5 EdgeOne的函数管理界面

添加环境变量

我们要添加环境变量,这一点非常重要!如图6,点击快速添加,其中的变量名为SECRET_KEY ,类型为String ,变量值为一串随机的字符(请注意!请不要填写123456等简单的密码,程序的防重放攻击 防绕过攻击全部依靠这个秘钥,并且这个秘钥不能泄露,我截图中的秘钥只是个例子)

图6 EdgeOne边缘函数添加环境变量的例子

确定后别忘记点击部署按钮完成环境变量下发,如图7所示,点击部署按钮

图7 环境变量部署下发

备注:SECRET_KEY是必须要进行填写的,否则人机验证将会直接报错,无法正常运行,我的这个脚本其他的可选环境变量在下面的表中提供

环境变量名称类型作用默认值
TTL_SECONDS数字通过验证后多少秒内无需再次验证5
DIFFICULTY数字Pow验证难度,数字越大越难,客户端解题所需时间越长4
SECRET_KEY子串符系统安全秘钥,加密以及防重放还有绕过都依赖他必须手动指定,否则脚本直接报错退出
PREFIX_TTL_SECONDS数字前缀有效期,代表一次挑战留给客户端最多多少秒进行解题,超过这个时间挑战失败300

设置匹配规则

接下来就是匹配对应的规则,指定页面/路径/条件 被边缘函数响应,然后下发挑战。

我这边以wordpress的搜索功能为例子,

我这个wordpress站点,如果要搜索 有三种方案,分别如下

https://www.q2019.com/?s=%E4%BD%A0%E5%A5%BD
https://www.q2019.com/wp-json/wp/v2/posts?search=%E4%BD%A0%E5%A5%BD
https://www.q2019.com/search/%E4%BD%A0%E5%A5%BD

我们就要限制上面三个页面/接口(因为搜索一次对于wordpress压力比较大),访问这三个页面 需要进行Pow工作量证明。

在函数管理中,选择了函数界面后,点击如图8圈出的部分,点击新增触发规则。

需要按照自己业务需求设定拦截规则,比如我说的刚刚三个接口,由于不好用文字描述规则,我截了一个长图。如图8所示

图8 EdgeOne 边缘函数拦截wordpress搜索功能的规则

点击确认后,稍等一段时间,尝试使用对应站点的搜索功能,可以看到执行搜索功能之前需要进行Pow了,如图9所示。

图9 触发了Pow验证的界面

当然,如果是对网页API接口使用这个方案防爆破,使接口需要Pow证明挑战,那么我简单的说下如何调用受到这个保护的接口,就是在首次请求中,按照刚刚给的代码中前端的计算代码,完成对应流程的计算挑战,将结果返回给接口,获取放行Cookie,然后后续请求中带上这个放行Cookie从而调用被保护的接口。

我这个方案的缺陷

说完了优点 肯定还是有缺点的,缺点如下

1、需要人工编写规则。没办法,因为是自己手搓的防刷系统,要自己一步步的调整规则。

2、EdgeOne仅对未被拦截的流量计费(即干净流量)[4],但是如果使用边缘函数的话,通过这种方案拦截的流量不会被腾讯记录为拦截了的流量,所以回被腾讯EdgeOne当做正常流量正常计费。

3、挑战可能对低性能设备有难度。当目标用户的CPU性能不够的时候,可能这个Pow证明会导致他们无法正常使用网站的功能(比如性能不够,导致即使挑战都过期了,都没办法算出一个正确的结果),并可能造成额外的性能损耗。

4、无法统计一个放行Cookie用了多少次,所以要隔一小段时间时间就要重新验证一次。因为腾讯的边缘函数处不像CLOUDFLARE WORKERS一样有数据储存方案,函数每一次都是重新执行的,无法记录一个Cookie使用过多少次,所以不能将放行Cookie的有效期设置的太长,否则容易被重放攻击。(即在同IP同UA下 将Cookie复用绕过认证)

5、这个拦不住机器人。这个方案主要是防止暴力破解以及刷接口的,而不是区分访客是不是人机的(虽然大部分情况下区分人机也是为了防止暴力破解的)。

后记

我用了一小段时间这个方案,每一次挑战(难度为)大约会消耗边缘函数CPU TIME 3-4ms,所以免费版本的300万毫秒额度大约是能够质询80-100万次的。

尾注

[1] 腾讯云 EdgeOne 免费套餐说明https://cloud.tencent.com/document/product/1552/118985

[2] 可以查看腾讯云的套餐对比说明https://cloud.tencent.com/document/product/1552/77384

[3] 边缘函数的回源文档https://cloud.tencent.com/document/product/1552/120717

[4]EdgeOne关于干净流量计费的说明https://cloud.tencent.com/document/product/1552/103577

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,记得载明出处。
内容有问题?想与我交流下?点此哦,欢迎前来交流~
上一篇