网站接上 Cloudflare 之后,几乎每天都能在日志里看到一堆莫名其妙的请求:有人在翻 /database.sql,有人在找 /wp-config.php,还有人在猜 /.env。这些不是正常访客,而是自动化扫描器在碰运气——只要你的备份文件恰好放在网站根目录,一次扫描就可能把整站数据带走。
Cloudflare 自带 WAF 可以拦这类请求,但免费版的自定义规则只有 5 条,配几个常用规则就见底了。这篇文章讲一个更省钱的办法:把判断逻辑放进 Worker,命中规则就返回 403,同时调用 Cloudflare API 把这个 IP 写进 IP Access Rules 黑名单。免费版够用,配置一次,后面基本不用管。
下面按“完全没接触过 Worker”的角度来写,每一步都说明点哪里、填什么、看到什么算成功。
不需要理解代码细节,先建立一个整体印象:
这里有个关键点值得记住: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。所以这层防护是“锦上添花”,不会成为单点故障。
小白最容易卡在这里,其实每个都不复杂:
true 时只记录、不真拉黑,适合先跑一遍看看效果。准备清单:一个已经接入 Cloudflare 的域名(NS 托管或 SaaS 方式 CNAME 接入都行)、能登录 Cloudflare 的账号、10 分钟时间。不需要服务器,不需要装任何软件。
登录 Cloudflare 后台,点进你要配置的域名,进入概述 / Overview 页面。
右侧栏拉到最下方,能看到一串 32 位字符,标注为区域 ID。英文界面下叫 Zone ID。复制下来存到记事本。

区域 ID 就在域名概览页的右侧栏底部,点一下即可复制。
还在这个页面,往下找获取您的 API 令牌(Get your API token),点进去。
选择创建令牌,模板选自定义令牌,然后按下面三项填写:
| 字段 | 填什么 |
|---|---|
| 令牌名称 | 随便起,比如 worker-auto-ban |
| 权限 | 区域 → 防火墙服务 → 编辑 |
| 区域资源 | 选择你要保护的那个域名 |
填完点继续以显示摘要,再点创建令牌。

权限只需要“防火墙服务 → 编辑”这一项,不要图省事给全局权限。
这一步最容易翻车:令牌只在创建成功后的那个页面完整显示一次,关掉就再也看不到了。务必先复制到记事本。如果忘了复制,直接删掉重建一个,不要试图找回。
左侧菜单进入 Workers & Pages,点创建应用程序(Create application)。

入口在 Workers & Pages 页面右上角。
在模板列表里选择 Hello World,也就是“开始使用 Hello World 模板”那个选项。不需要连接 GitHub,也不需要上传文件。

模板页有三个选项,选中间那个 Hello World。
给 Worker 起个名字(比如 ip-auto-ban),点部署。部署成功后会看到一个默认的 Hello World 响应页面。

名字随便起,部署后还能改代码。
点编辑代码进入在线编辑器,把里面默认的几行代码全选删除。

编辑器左侧是代码区,右上角是部署按钮。
然后把下面这段代码完整粘贴进去:
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 |
文本 |

前两个建议用“密钥”类型,界面上不会回显,截图分享时也不容易泄露。
关于 DRY_RUN,多说一句:代码里写的是“只有明确等于 false 才真拉黑”,也就是说你忘了填这个变量,它默认就是演练模式。这个设计是故意的,避免配置没弄好就开始封 IP。
Worker 部署完还不会自动接管流量,必须绑定路由。
进入 Worker 的设置 → 域和路由 → 添加路由,填三项:
www.example.com/*(换成你的域名)
路由写 www.域名/*,覆盖该域名下所有路径。
如果你的裸域(不带 www)也接入了 Cloudflare,再加一条 example.com/*。两条路由指向同一个 Worker 即可。
失败模式必须选“失败时自动打开”。选另一个选项的话,Worker 一报错或者当天 10 万次额度用完,你的网站会直接给访客返回错误页。选“自动打开”则是遇到问题就绕过 Worker、正常回源,最坏情况只是当天不再拦截。

这个下拉框是本教程里最不能选错的一项。
变量里 DRY_RUN 现在是 true,正好用来验证。
浏览器访问一个肯定会被拦的地址,比如:
https://www.example.com/database.sql
正常的话,你会看到一个只有 Forbidden 字样的白底页面。这说明 Worker 已经识别到敏感路径并返回了 403。

看到这个页面,说明规则生效了。此时还不会真的拉黑 IP。
接着回到 Cloudflare,进入这个 Worker 的日志页面(Workers & Pages → 选中 Worker → 日志),刷新几次刚才那个地址,就能看到实时日志。

日志是排查问题最直接的地方,后面所有“为什么没生效”都先看这里。
日志里会出现类似 SENSITIVE PATH MATCH 的记录,包含访客 IP、请求路径、命中的检测项,以及当前是不是演练模式。

演练模式下会额外打印 DRY RUN API CHECK OK,说明 API 读取权限和 Zone ID 都没问题。
确认日志正常后,回到变量页面,把 DRY_RUN 改成 false。从这一刻起,命中规则的 IP 会被真正写入黑名单。
测试时注意一件事:如果你的站点做了国内外分流,境内流量直连源站、不经过 Cloudflare,那你在国内访问测试地址根本不会触发 Worker,看到的可能是源站返回的 404。这种情况需要用境外网络测试,但测试 IP 会被真的拉黑——所以务必先保持
DRY_RUN=true。
进入域名管理,左侧菜单找到安全性 → WAF,页面往下拉,就能看到 IP Access Rules 列表。
每一条记录都带注释,格式像这样:
Auto-ban: configured sensitive item; item=database.sql; path=/database.sql
注释里写清了是哪个检测项、哪个路径触发的,方便你判断是误伤还是真攻击。单条误伤直接点右侧删除即可。

列表里的 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 在扫你的站——某种程度上,这也是一个免费的攻击面监控面板。