AI クローラーを騙った認証情報スキャン(/.env や /wp-config.php を叩くやつ)を、Cloudflare の WAF で遮断しました。入口で止まれば解析の数字も綺麗になる、と思っていました。
1か月後の数字はそうなりませんでした。遮断していた Perplexity-User は8日間で595リクエスト計上されたまま、内訳は403のみ。さらにスキャンは、遮断していない Amazonbot や ChatGPT-User という本物のクローラーが使っているのと同じ名前に移っていました。
WAF で偽装 UA を弾くときに先に知っておきたかったことを、実測つきでまとめます。
遮断したリクエストは解析に残る
Cloudflare の GraphQL Analytics API(httpRequestsAdaptiveGroups)は、エッジが返した応答を集計します。WAF でブロックしたリクエストは消えず、403 として計上されます。
Perplexity-User を名乗るリクエストの推移がそのまま証拠になりました。ルール投入前は404(オリジンに到達して存在しないパス)、投入後は403(エッジで遮断)。ステータスが変わっただけで、件数は増えています。
| スナップショット(8日窓) | 403 | 404 |
|---|---|---|
| 08-03(ルール投入当日) | 1 | 4 |
| 08-08 | 2 | 4 |
| 08-16 | 297 | 0 |
| 08-22 | 311 | 0 |
| 08-28 | 595 | 0 |
遮断は効いています。404 が消えたので、リクエストはオリジンに届いていません。それでも「AI クローラーからのアクセス数」を UA 別に数える限り、この595件は数字の中に残ります。
つまり WAF はオリジンを守る道具で、集計を直す道具ではありません。 計測の汚染を落としたいなら、取得側(集計スクリプト側)で分類する必要があります。
入れたルール2本
対象は Cloudflare Pages 上の静的サイト、無料プランのゾーンです。ルールは API から冪等に投入していて、description を目印に、あれば更新・無ければ作成します。
1本目はパスで弾きます。UA は見ません。
SCAN_PATH_TOKENS = [
"/.env", "/.git", "/.aws", "/.svn", "/.ssh",
"/wp-", "wp-admin", "wp-login", "wp-includes", "wp-config",
"wlwmanifest", "xmlrpc.php",
"secrets.json", "service_account", "/actuator/", "/api/auth/session",
"phpinfo", "/.npmrc", "/.htpasswd", "id_rsa", "config.json",
]
scan_expr = " or ".join(
f'(http.request.uri.path contains "{t}")' for t in SCAN_PATH_TOKENS
)
2本目は UA で弾きます。not cf.client.bot(Cloudflare が検証できていない)を条件に付けるのが要点です。
SPOOFED_ONLY_UAS = [
"Bytespider", "cohere-ai", "anthropic-ai", "Perplexity-User", "Google-Extended",
]
ua_expr = "(not cf.client.bot) and (" + " or ".join(
f'http.user_agent contains "{u}"' for u in SPOOFED_ONLY_UAS
) + ")"
投入は Rulesets API に PUT するだけです。
API_BASE = "https://api.cloudflare.com/client/v4"
PHASE = "http_request_firewall_custom"
# 既存ルールを取得 → description が一致するものを差し替え → 全体を PUT
entrypoint = api(token, "GET", f"/zones/{zone_id}/rulesets/phases/{PHASE}/entrypoint")
rules = entrypoint["result"]["rules"]
# ... TAG(description の接頭辞)で一致するものを新しい定義に置き換える ...
api(token, "PUT", f"/zones/{zone_id}/rulesets/{ruleset_id}", {"rules": rules})
トークンは Zone/WAF/Edit + Zone/Zone/Read の2権限で足ります。解析取得用のトークン(Analytics 読み取りのみ)では403になるので、別に発行します。
ハマりどころ1: starts_with はルート直下しか一致しない
ゾーンには元から手動で作ったルールがありました。
starts_with(http.request.uri.path, "/.env")
これだと /.env は止まりますが、実際に来ていた /server/.env(18回)・/admin/.env・/app/.git/HEAD は全部すり抜けます。ルールがあるのにスキャンが続いている状態でした。contains に変えると深い階層も止まります。
http.request.uri.path は正規化後のパスで判定されるので、%2e のようなエンコード回避も効きません。
ハマりどころ2: ブラックリストは後追いになる
パスのトークン一覧は、書いた分しか止まりません。08-28 の集計には、AI 名乗りのリクエストの404が300件残っていました。叩かれていたのは以下のようなパスで、どれも一覧に入っていません。
-
/secrets.yml…secrets.jsonは書いたが.ymlは書いていない -
/id_ecdsa…id_rsaは書いたがid_ecdsaは書いていない /.zshrc/@fs/proc/self/environ
config.json も最初は eq "/config.json" の完全一致で書いていて、/runtime-config.json と /api/runtime-config.json に抜けられました。トークンを足すときは、サイトマップの実在パスに同じ文字列を含むページが無いかを確認してから広げるのがおすすめです(広げすぎると正当な配信物を巻き込みます)。
弾いた名前は捨てられ、本物と同じ名前に移った
ここがいちばん効いた発見でした。スキャン判定になったリクエストを名乗り別に並べると、こうなります。
| 名乗っている UA | 08-08 | 08-16 | 08-22 | 08-28 |
|---|---|---|---|---|
| Perplexity-User(ルール2で遮断) | 4 | 147 | 173 | 387 |
| Amazonbot | 0 | 67 | 284 | 119 |
| ChatGPT-User | 21 | 51 | 181 | 67 |
| OAI-SearchBot | 9 | 19 | 74 | 25 |
| GPTBot | 16 | 18 | 72 | 26 |
| ClaudeBot | 13 | 16 | 70 | 27 |
遮断した Perplexity-User はその後も使われ続け、そこに弾いていない名前が上乗せされました。叩くパスの種類が増えたのではなく、同じ種類のスキャンが複数の名乗りに広がっただけです。
やっかいなのは、Amazonbot と ChatGPT-User は本物も同じ文字列で来ることです。同じ8日間の検証結果はこうでした。
| 名乗り | 検証済み | 未検証 |
|---|---|---|
| Amazonbot | 135 | 489 |
| ChatGPT-User | 211 | 336 |
UA 一致で弾けば、記事を216件取りに来た本物の ChatGPT-User も一緒に落ちます。UA だけを条件にしたブロックリストは、偽装が「本物と共有する名前」に移った時点で使えなくなります。
逆に、実在しない名前は恒久的に弾けます。Google-Extended は HTTP リクエスト用の User-Agent を持たないと Google のクローラー一覧に明記されていて(robots.txt の制御用トークンです)、名乗った時点で全て偽装だからです。anthropic-ai や cohere-ai も同じ扱いにしています。
まとめ
- WAF でブロックしても、解析には403として残る。遮断=計測の修正ではない
- 実在しない UA(
Google-Extendedなど)は UA 一致で弾いてよい - 本物と共有する UA(
ChatGPT-User/Amazonbot)は UA で弾けない。cf.client.botやverifiedBotCategoryの検証結果で判断する側に寄せる - パスのブラックリストは
containsで書き、サイトマップと突き合わせて広げる
「対策したのにスキャンが減らない → 減った」の推移をどう評価するか(ルール無変更の週に急減し、ゾーン全体の403は増えていた話)、遮断と計測を別レイヤーに分けた運用の形は Aulvem 本家にまとめました → Aulvem|偽装UAをWAFで弾いた1か月 — 遮断した403も解析に残り、スキャンは本物と同じ名前に移った