免费版 WAF 规则不够用?Worker 自动拉黑扫描 IP 方案
网站接上 Cloudflare 之后,几乎每天都能在日志里看到一堆莫名其妙的请求:有人在翻 /database.sql,有人在找 /wp-config.php,还有人 2026-11-1 05:31:31 Author: blog.upx8.com(查看原文) 阅读量:7 收藏

网站接上 Cloudflare 之后,几乎每天都能在日志里看到一堆莫名其妙的请求:有人在翻 /database.sql,有人在找 /wp-config.php,还有人在猜 /.env。这些不是正常访客,而是自动化扫描器在碰运气——只要你的备份文件恰好放在网站根目录,一次扫描就可能把整站数据带走。

Cloudflare 自带 WAF 可以拦这类请求,但免费版的自定义规则只有 5 条,配几个常用规则就见底了。这篇文章讲一个更省钱的办法:把判断逻辑放进 Worker,命中规则就返回 403,同时调用 Cloudflare API 把这个 IP 写进 IP Access Rules 黑名单。免费版够用,配置一次,后面基本不用管。

下面按“完全没接触过 Worker”的角度来写,每一步都说明点哪里、填什么、看到什么算成功。

先说清楚:这套方案是怎么工作的

不需要理解代码细节,先建立一个整体印象:

  1. 访客的请求到达 Cloudflare。
  2. Cloudflare 先跑完你设置好的 WAF 规则,再进入 Worker。
  3. Worker 检查这次请求的路径,看看是不是在“敏感文件清单”里。
  4. 不在清单里,原样转发给源站,网站照常打开。
  5. 在清单里,立刻返回 403,同时调用 Cloudflare API,把这个 IP 加入 IP Access Rules 黑名单。

这里有个关键点值得记住:IP Access Rules 的黑名单独立于 Worker。哪天你把 Worker 删了、额度用完了、代码写错了,已经拉黑的 IP 依然会被 Cloudflare 挡在门外。

判断逻辑跑在 WAF 之后、CDN 缓存之前,所以对正常页面的影响很小。也正因为要尽量少影响速度,这套代码没有用数据库存状态,只做纯内存的字符串匹配。

免费版够不够用

项目 免费版额度
IP Access Rules 规则数 每个账号最多 5 万条(各套餐一致)
Worker 请求数 每天 10 万次,UTC 0 点重置
Worker 单次 CPU 时间 10 毫秒(网络等待不计入)
额度归属 同一账号下所有 Worker 共享 10 万次

10 万次对中小网站是够的。额度用完后 Worker 会停止执行,配合路由里“失败时自动打开”的设置,请求会直接回源,网站照常访问,只是当天不再拉黑新 IP。所以这层防护是“锦上添花”,不会成为单点故障。

开始前,先搞懂四个名词

小白最容易卡在这里,其实每个都不复杂:

  • Zone ID(区域 ID):Cloudflare 给每个域名分配的唯一编号。API 调用时要告诉它“操作哪个域名”,填的就是这个。
  • API Token(API 令牌):给程序用的钥匙。它和你的登录密码不是一回事,可以只授予“防火墙编辑”这一项权限,泄露了危害也有限。
  • Worker:一段跑在 Cloudflare 边缘节点上的 JavaScript 代码。请求会先经过它,你可以在里面写任意判断逻辑。
  • DRY_RUN:演练开关。设为 true 时只记录、不真拉黑,适合先跑一遍看看效果。

准备清单:一个已经接入 Cloudflare 的域名(NS 托管或 SaaS 方式 CNAME 接入都行)、能登录 Cloudflare 的账号、10 分钟时间。不需要服务器,不需要装任何软件。

第一步:复制 Zone ID

登录 Cloudflare 后台,点进你要配置的域名,进入概述 / Overview 页面。

右侧栏拉到最下方,能看到一串 32 位字符,标注为区域 ID。英文界面下叫 Zone ID。复制下来存到记事本。

在 Cloudflare 域名概述页右下角复制 Zone ID

区域 ID 就在域名概览页的右侧栏底部,点一下即可复制。

第二步:创建一个只管防火墙的 API 令牌

还在这个页面,往下找获取您的 API 令牌(Get your API token),点进去。

选择创建令牌,模板选自定义令牌,然后按下面三项填写:

字段 填什么
令牌名称 随便起,比如 worker-auto-ban
权限 区域 → 防火墙服务 → 编辑
区域资源 选择你要保护的那个域名

填完点继续以显示摘要,再点创建令牌。

创建 API 令牌时选择区域防火墙服务编辑权限

权限只需要“防火墙服务 → 编辑”这一项,不要图省事给全局权限。

这一步最容易翻车:令牌只在创建成功后的那个页面完整显示一次,关掉就再也看不到了。务必先复制到记事本。如果忘了复制,直接删掉重建一个,不要试图找回。

第三步:创建一个 Worker

左侧菜单进入 Workers & Pages,点创建应用程序(Create application)。

在 Workers & Pages 页面点创建应用程序

入口在 Workers & Pages 页面右上角。

在模板列表里选择 Hello World,也就是“开始使用 Hello World 模板”那个选项。不需要连接 GitHub,也不需要上传文件。

选择 Hello World 模板创建 Worker

模板页有三个选项,选中间那个 Hello World。

给 Worker 起个名字(比如 ip-auto-ban),点部署。部署成功后会看到一个默认的 Hello World 响应页面。

填写 Worker 名称并部署

名字随便起,部署后还能改代码。

第四步:把默认代码换成我们的规则

点编辑代码进入在线编辑器,把里面默认的几行代码全选删除。

进入 Worker 在线代码编辑器

编辑器左侧是代码区,右上角是部署按钮。

然后把下面这段代码完整粘贴进去:

const CF_API_BASE = "https://api.cloudflare.com/client/v4";

/*
* ==================== CUSTOM DETECTION LIST ====================
* Add one item per line. Blank lines and lines beginning with # are ignored.
*
* Matching rules:
* 1. A filename without / matches that filename in ANY directory.
* Example: database.sql matches /database.sql and /wp-content/uploads/database.sql
* 2. An item containing / is treated as a path and matches that path exactly.
* Example: /wp-content/.env matches only that exact path.
* 3. * is a wildcard (zero or more characters).
* Example: .env* matches .env, .env.local, .env.production, etc.
* 4. Matching is case-insensitive. Items are literal text, not regular expressions.
* 5. `www\\.zip` is also accepted and normalized to `www.zip` for convenience
* when copying paths that have escaped dots in Markdown or logs.
*
* Be cautious with broad entries such as *.zip or *.sql: they may match legitimate
* downloads. Start with specific filenames and paths, then customize as needed.
*/
const SENSITIVE_ITEMS_TEXT = String.raw`
# Environment/configuration files
.env
.env.local
.env.production
.env.development
.env.staging
wp-config.php
wp-config-sample.php
/wp-content/.env
/wp-includes/.env
/wp-content/plugins/.env
/wp-content/themes/.env
/.git/config

# Database files and exports
database.sql
database.sql.gz
database.sqlite
database.sqlite3
database.zip
database-dump.sql
database-backup.zip
db.sql
db.sql.gz
db.zip
db_backup.sql
db_backup.zip
data.db
data.sql
data.sqlite
data.sqlite3
mysql.sql
mysql.sql.gz
mysql.zip
backup.sql
backup.sql.gz
backup.sql.zip
backup.tar.gz
backup.zip
backup.7z
backup.rar
backup.bak
dump.sql
dump.zip
site.sql
site.zip

# Website/source archives from common scanner probes
cheshirex.zip
cheshirex.com.zip
wordpress.zip
wp.zip
www\.zip
wwwroot.zip
webroot.zip
public_html.zip
htdocs.zip
html.zip
source.zip
full.zip
archive.zip
archives.zip

# Add your own entries below, one per line
`;

function normalizePath(rawPath) {
  let path = String(rawPath || "/");

  // Decode encoded paths (including double-encoded probes) safely, at most twice.
  for (let i = 0; i < 2; i++) {
    try {
      const decoded = decodeURIComponent(path);
      if (decoded === path) break;
      path = decoded;
    } catch {
      break;
    }
  }

  // Interpret escaped-dot paths such as /www\\.zip as /www.zip, then canonicalize slashes.
  path = path
    .replace(/\\\./g, ".")
    .replace(/\\/g, "/")
    .replace(/\/{2,}/g, "/")
    .toLowerCase();

  if (path.length > 1) path = path.replace(/\/+$/, "");
  return path || "/";
}

function globToRegExp(pattern) {
  const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
  return new RegExp(`^${escaped.replace(/\*/g, ".*")}$`, "i");
}

function buildDetectionList(text) {
  return text
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => line && !line.startsWith("#"))
    .map((rawItem) => {
      // Allow `www\\.zip` from escaped Markdown/log tables to mean `www.zip`.
      let item = rawItem.replace(/\\\./g, ".").trim().toLowerCase();
      const isPath = item.includes("/");

      if (isPath) {
        if (!item.startsWith("/")) item = `/${item}`;
        item = normalizePath(item);
      }

      return {
        rawItem,
        item,
        isPath,
        matcher: globToRegExp(item),
      };
    });
}

const SENSITIVE_ITEMS = buildDetectionList(SENSITIVE_ITEMS_TEXT);

function detectSensitivePath(path) {
  const segments = path.split("/").filter(Boolean);
  const basename = segments.length ? segments[segments.length - 1] : "";

  const matched = SENSITIVE_ITEMS.find((entry) => {
    return entry.isPath
      ? entry.matcher.test(path)
      : entry.matcher.test(basename);
  });

  if (!matched) return null;

  return {
    reason: "configured sensitive item",
    matchedItem: matched.rawItem,
  };
}

function forbiddenResponse() {
  return new Response("Forbidden", {
    status: 403,
    headers: {
      "Content-Type": "text/plain; charset=UTF-8",
      "Cache-Control": "no-store",
      "X-Content-Type-Options": "nosniff",
    },
  });
}

function safeJsonStringify(value) {
  try {
    return JSON.stringify(value);
  } catch {
    return String(value);
  }
}

async function readJson(response) {
  try {
    return await response.json();
  } catch {
    return null;
  }
}

function apiErrorText(response, payload) {
  const errors = Array.isArray(payload?.errors)
    ? payload.errors
        .map((item) => `${item.code ?? "?"}: ${item.message ?? "Unknown API error"}`)
        .join("; ")
    : "";

  return `HTTP ${response.status}; ${errors || safeJsonStringify(payload) || "No JSON response"}`;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = normalizePath(url.pathname);
    const match = detectSensitivePath(path);

    if (!match) {
      // Avoid a self-fetch loop when someone opens the workers.dev URL directly.
      if (url.hostname.endsWith(".workers.dev")) {
        return new Response("ip-auto-ban is running; no sensitive item matched.", {
          status: 200,
          headers: {
            "Content-Type": "text/plain; charset=UTF-8",
            "Cache-Control": "no-store",
          },
        });
      }
      return fetch(request);
    }

    const ip = (request.headers.get("CF-Connecting-IP") || "").trim();
    if (!ip) {
      console.error("Sensitive item matched, but CF-Connecting-IP is missing", {
        path,
        matchedItem: match.matchedItem,
      });
      return forbiddenResponse();
    }

    // Fail-safe: persistent blocking only occurs when DRY_RUN is explicitly "false".
    const dryRun = String(env.DRY_RUN ?? "true").trim().toLowerCase() !== "false";
    const zoneId = String(env.CF_ZONE_ID ?? "").trim();
    const apiToken = String(env.CF_API_TOKEN ?? "").trim();
    const allowlist = String(env.ALLOWLIST_IPS ?? "")
      .split(/[\s,;]+/)
      .map((item) => item.trim().toLowerCase())
      .filter(Boolean);
    const isAllowlisted = allowlist.includes(ip.toLowerCase());

    console.log("SENSITIVE PATH MATCH", {
      ip,
      path,
      matchedItem: match.matchedItem,
      dryRun,
      isAllowlisted,
    });

    if (!zoneId || !apiToken) {
      console.error("Auto-ban is not configured: missing CF_ZONE_ID or CF_API_TOKEN", {
        ip,
        path,
        dryRun,
        hasZoneId: Boolean(zoneId),
        hasApiToken: Boolean(apiToken),
      });
      return forbiddenResponse();
    }

    const apiUrl = new URL(
      `${CF_API_BASE}/zones/${encodeURIComponent(zoneId)}/firewall/access_rules/rules`
    );
    const lookupUrl = new URL(apiUrl.toString());

    // Use the IPv4 target filter for IPv4. For IPv6, query by exact configuration value.
    if (!ip.includes(":")) {
      lookupUrl.searchParams.set("configuration.target", "ip");
    }
    lookupUrl.searchParams.set("configuration.value", ip);
    lookupUrl.searchParams.set("per_page", "100");
    lookupUrl.searchParams.set("page", "1");

    const headers = {
      Authorization: `Bearer ${apiToken}`,
      "Content-Type": "application/json",
    };

    try {
      // Query first even in DRY_RUN to verify read access and Zone ID.
      const lookupResponse = await fetch(lookupUrl.toString(), {
        method: "GET",
        headers,
      });
      const lookup = await readJson(lookupResponse);

      if (!lookupResponse.ok || !lookup?.success) {
        throw new Error(
          `Query IP Access Rules failed: ${apiErrorText(lookupResponse, lookup)}`
        );
      }

      const matches = (Array.isArray(lookup.result) ? lookup.result : []).filter(
        (rule) =>
          String(rule.configuration?.value ?? "").toLowerCase() === ip.toLowerCase()
      );

      // Never override an existing manual whitelist.
      const whitelistRules = matches.filter((rule) => rule.mode === "whitelist");
      if (whitelistRules.length) {
        console.warn("MANUAL WHITELIST FOUND; persistent auto-ban skipped", {
          ip,
          path,
          ruleIds: whitelistRules.map((rule) => rule.id),
        });
        return forbiddenResponse();
      }

      if (isAllowlisted) {
        console.warn("ALLOWLISTED IP; persistent auto-ban skipped", {
          ip,
          path,
          matchedItem: match.matchedItem,
        });
        return forbiddenResponse();
      }

      const alreadyBlocked = matches.some((rule) => rule.mode === "block");

      if (dryRun) {
        console.log("DRY RUN API CHECK OK", {
          ip,
          path,
          matchedItem: match.matchedItem,
          existingRules: matches.map((rule) => ({ id: rule.id, mode: rule.mode })),
          alreadyBlocked,
          createRequestSkipped: true,
        });
        return forbiddenResponse();
      }

      if (alreadyBlocked) {
        console.log("IP ALREADY BLOCKED; no duplicate rule created", { ip, path });
        return forbiddenResponse();
      }

      const createResponse = await fetch(apiUrl.toString(), {
        method: "POST",
        headers,
        body: JSON.stringify({
          configuration: {
            target: ip.includes(":") ? "ip6" : "ip",
            value: ip,
          },
          mode: "block",
          notes: `Auto-ban: ${match.reason}; item=${match.matchedItem}; path=${path.slice(0, 120)}`,
        }),
      });
      const created = await readJson(createResponse);

      if (!createResponse.ok || !created?.success) {
        throw new Error(
          `Create IP Access Rule failed: ${apiErrorText(createResponse, created)}`
        );
      }

      console.warn("IP ACCESS RULE CREATED", {
        ip,
        path,
        matchedItem: match.matchedItem,
        ruleId: created.result?.id,
      });
    } catch (error) {
      console.error("AUTO-BAN FAILED", {
        ip,
        path,
        matchedItem: match.matchedItem,
        error: String(error?.message || error),
      });
    }

    // Deny the current sensitive-file request even if the API fails.
    return forbiddenResponse();
  },
};

粘贴完不要急着部署,先改一处地方:找到 # Website/source archives from common scanner probes 这一段,把 cheshirex.zip 和 cheshirex.com.zip 换成你自己的域名,比如 example.com.zip、example.zip。这两行的作用是提前拦住“扫描器直接猜整站压缩包”的行为。

把默认代码替换成规则代码后点部署

代码粘贴完成后点右上角的部署,这一步只是保存,还不会真的拉黑。

检测清单怎么写

清单在 SENSITIVE_ITEMS_TEXT 里,一行一个,规则很简单:

写法 含义
database.sql 匹配任意目录下叫这个名字的文件
/wp-content/.env 只匹配这个完整路径
*.sql 匹配任意目录下以 .sql 结尾的文件
www\.zip 会自动当作 www.zip 处理
# 开头的行 注释,不参与检测
空行 直接忽略

不建议一上来就写 *.sql 或 *.zip。这类通配太宽,可能把你自己的正常下载文件也拦掉。先用具体文件名跑一段时间,看看日志里真实被扫的是哪些路径,再逐步收紧。

第五步:填四个变量

代码本身不含任何密钥,所有配置都从环境变量读。在 Worker 的设置 → 变量和密钥里添加下面四项:

变量名 填什么 类型
ALLOWLIST_IPS 你自己的固定 IP,多个用逗号分隔 密钥
CF_API_TOKEN 第二步创建的 API 令牌 密钥
CF_ZONE_ID 第一步复制的区域 ID 文本
DRY_RUN 先填 true 文本

在 Worker 的变量和密钥页面添加四个变量

前两个建议用“密钥”类型,界面上不会回显,截图分享时也不容易泄露。

关于 DRY_RUN,多说一句:代码里写的是“只有明确等于 false 才真拉黑”,也就是说你忘了填这个变量,它默认就是演练模式。这个设计是故意的,避免配置没弄好就开始封 IP。

第六步:绑定路由

Worker 部署完还不会自动接管流量,必须绑定路由。

进入 Worker 的设置 → 域和路由 → 添加路由,填三项:

  • 区域:选择你的域名
  • 路由:www.example.com/*(换成你的域名)
  • 失败模式:选失败时自动打开(继续)

添加 Worker 路由并选择失败时自动打开

路由写 www.域名/*,覆盖该域名下所有路径。

如果你的裸域(不带 www)也接入了 Cloudflare,再加一条 example.com/*。两条路由指向同一个 Worker 即可。

失败模式必须选“失败时自动打开”。选另一个选项的话,Worker 一报错或者当天 10 万次额度用完,你的网站会直接给访客返回错误页。选“自动打开”则是遇到问题就绕过 Worker、正常回源,最坏情况只是当天不再拦截。

失败模式选择失败时自动打开继续

这个下拉框是本教程里最不能选错的一项。

第七步:先演练,再上线

变量里 DRY_RUN 现在是 true,正好用来验证。

浏览器访问一个肯定会被拦的地址,比如:

https://www.example.com/database.sql

正常的话,你会看到一个只有 Forbidden 字样的白底页面。这说明 Worker 已经识别到敏感路径并返回了 403。

访问 database.sql 返回 Forbidden 拦截页面

看到这个页面,说明规则生效了。此时还不会真的拉黑 IP。

接着回到 Cloudflare,进入这个 Worker 的日志页面(Workers & Pages → 选中 Worker → 日志),刷新几次刚才那个地址,就能看到实时日志。

从 Workers 页面进入 Worker 日志

日志是排查问题最直接的地方,后面所有“为什么没生效”都先看这里。

日志里会出现类似 SENSITIVE PATH MATCH 的记录,包含访客 IP、请求路径、命中的检测项,以及当前是不是演练模式。

日志中显示命中敏感路径的请求详情

演练模式下会额外打印 DRY RUN API CHECK OK,说明 API 读取权限和 Zone ID 都没问题。

确认日志正常后,回到变量页面,把 DRY_RUN 改成 false。从这一刻起,命中规则的 IP 会被真正写入黑名单。

测试时注意一件事:如果你的站点做了国内外分流,境内流量直连源站、不经过 Cloudflare,那你在国内访问测试地址根本不会触发 Worker,看到的可能是源站返回的 404。这种情况需要用境外网络测试,但测试 IP 会被真的拉黑——所以务必先保持 DRY_RUN=true。

怎么查看被拉黑的 IP,怎么解封

进入域名管理,左侧菜单找到安全性 → WAF,页面往下拉,就能看到 IP Access Rules 列表。

每一条记录都带注释,格式像这样:

Auto-ban: configured sensitive item; item=database.sql; path=/database.sql

注释里写清了是哪个检测项、哪个路径触发的,方便你判断是误伤还是真攻击。单条误伤直接点右侧删除即可。

IP Access Rules 列表中显示自动拉黑的 IP 和触发原因

列表里的 Notes 就是 Worker 写入的触发原因。

一键清空所有自动拉黑记录

拉黑容易,清理烦。规则跑久了可能积累几百条,手动删不现实。把下面这段脚本保存为 clear-auto-ban.ps1,在 Windows 上右键“使用 PowerShell 运行”。

$ErrorActionPreference = "Stop"

$zoneId = (Read-Host "Enter the Zone ID").Trim()

if ([string]::IsNullOrWhiteSpace($zoneId)) {
    Write-Host "Zone ID is required."
    exit 1
}

$secureToken = Read-Host "Enter Cloudflare API Token" -AsSecureString

$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)

try {
    $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
}
finally {
    [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}

$headers = @{
    Authorization = "Bearer $token"
}

$base = "https://api.cloudflare.com/client/v4/zones/$zoneId/firewall/access_rules/rules"
$rules = [System.Collections.Generic.List[object]]::new()
$page = 1

try {
    Write-Host "`nQuerying Auto-ban rules..."

    while ($true) {
        $uri = "${base}?notes=Auto-ban&per_page=100&page=$page"

        $response = Invoke-RestMethod `
            -Method Get `
            -Uri $uri `
            -Headers $headers

        if (-not $response.success) {
            throw "Cloudflare API returned success=false."
        }

        $rows = @($response.result)

        foreach ($rule in $rows) {
            if (
                $rule.mode -eq "block" -and
                [string]$rule.notes -like "Auto-ban:*"
            ) {
                $rules.Add($rule)
            }
        }

        if ($rows.Count -lt 100) {
            break
        }

        $page++
    }

    Write-Host "`nFound $($rules.Count) matching rules."

    if ($rules.Count -eq 0) {
        Write-Host "No matching Auto-ban rules found."
        exit 0
    }

    $rules |
        ForEach-Object {
            [PSCustomObject]@{
                IP    = $_.configuration.value
                Notes = $_.notes
                ID    = $_.id
            }
        } |
        Format-Table -AutoSize -Wrap

    Write-Host ""
    Write-Host "WARNING: Only the rules listed above will be considered."
    Write-Host "Review every IP and note before continuing."

    $confirm = Read-Host "Type DELETE AUTO-BAN RULES to delete these rules"

    if ($confirm -cne "DELETE AUTO-BAN RULES") {
        Write-Host "Cancelled. No rules were deleted."
        exit 0
    }

    foreach ($rule in $rules) {
        try {
            $deleteUrl = "$base/$($rule.id)"

            $result = Invoke-RestMethod `
                -Method Delete `
                -Uri $deleteUrl `
                -Headers $headers

            if ($result.success) {
                Write-Host "Deleted: $($rule.configuration.value)"
            }
            else {
                Write-Warning "Delete failed: $($rule.configuration.value)"
            }
        }
        catch {
            Write-Warning (
                "Delete failed for {0}: {1}" -f
                $rule.configuration.value,
                $_.Exception.Message
            )
        }
    }

    Write-Host "`nFinished."
}
catch {
    Write-Host "`nERROR: $($_.Exception.Message)" -ForegroundColor Red
}
finally {
    $headers = $null
    $token = $null
    $secureToken = $null
}

运行后按提示输入 Zone ID 和 API 令牌,脚本会先列出所有自动拉黑的 IP 和原因,不会立刻删除。确认无误后再输入 DELETE AUTO-BAN RULES,才会逐条清除。

脚本只处理注释以 Auto-ban: 开头的规则,你自己手动加的黑名单不会被误删——这是它相对“全量清空”最实用的地方。

几个容易踩的坑

看到 403 但黑名单里没有记录。 大概率是 DRY_RUN 还是 true,或者这个 IP 在 ALLOWLIST_IPS 里,又或者你之前给它加过手动白名单。这三种情况下代码都会返回 403 但不写黑名单。

注意:白名单不等于放行。 ALLOWLIST_IPS 的作用是“不持久拉黑”,请求依然会被拒绝并返回 403。它保护的是你自己不被自己的规则封掉,不是让敏感路径可以访问。

日志里什么都没有。 检查路由有没有绑上、绑的是 www 还是裸域、域名是否真的走了 Cloudflare 代理(DNS 记录要开橙色云朵)。国内直连源站的流量不会经过 Worker。

改了检测清单不生效。 改完代码必须点一次部署,只在编辑器里保存不算。

Worker 突然全站报错。 先看是不是当天 10 万次额度用完了。如果路由的失败模式选的是“失败时自动关闭”,额度耗尽就会直接影响访客;改成“失败时自动打开”即可避免。

令牌一直报权限错误。 检查权限是不是“区域 → 防火墙服务 → 编辑”,以及区域资源里有没有勾选对应域名。只给“读取”权限是写不进黑名单的。

这套方案能挡什么,挡不住什么

能挡的:自动化扫描器对备份文件、配置文件、源码包的路径探测。这类请求特征非常明显——正常访客不会去访问 /database.sql,所以误伤率极低。

挡不住的:页面层的暴力破解、CC 攻击、爬虫抓正文。这些请求路径都是正常的,路径匹配逻辑识别不出来,需要配合 Cloudflare 的速率限制(Rate Limiting)或 WAF 托管规则一起用。

另外要接受一个前提:Worker 在缓存之前执行,会给每个请求增加一点点处理时间。免费版单次请求的 CPU 时间上限是 10 毫秒(等待网络响应的时间不计入),这段代码只做几十条字符串匹配,正常不会触发上限。对普通访客的影响基本可以忽略,但高并发场景仍建议自行压测确认。

常见问题

Q:免费版真的能一直用吗?

可以。IP Access Rules 每个账号上限 5 万条,Worker 每天 10 万次请求,对中小站点足够。只要路由的失败模式设成“自动打开”,额度用完也不会挂站,只是当天不再新增拉黑。

Q:删掉 Worker 之后,之前拉黑的 IP 会恢复访问吗?

不会。黑名单存在 IP Access Rules 里,和 Worker 无关,需要手动删或跑上面的清理脚本。

Q:能不能只拦某个目录?

可以。在清单里写完整路径,比如 /wp-content/uploads/backup.zip,就只匹配这一个地址。带 / 的条目按完整路径处理,不带 / 的按文件名匹配任意目录。

Q:多个域名要配几遍?

每个域名一个 Zone ID,建议一个域名配一个 Worker,变量分开填。一个 Worker 也能通过改代码支持多域名,但配置会复杂不少,不如分开清晰。

Q:为什么不用 Cloudflare 的托管规则?

托管规则(WAF Managed Rules)确实更强,但免费版只能用 Cloudflare 提供的免费规则集,想按自己的站情定制还是得写自定义规则。两者不冲突,这套 Worker 更像是给免费版补一个“能无限扩展的规则位”。


整套配置的核心其实就三件事:给 Worker 一份敏感路径清单、给它一把只能改防火墙的钥匙、把域名流量引到它面前。剩下的都是细节。

配好之后,你基本不用再管它。偶尔去 IP Access Rules 里看一眼,就能知道最近有哪些 IP 在扫你的站——某种程度上,这也是一个免费的攻击面监控面板。


文章来源: https://blog.upx8.com/WAF-Worker-IP
如有侵权请联系:admin#unsafe.sh