目次12

AI系の User-Agent を騙った認証情報スキャンを、Cloudflare の WAF で 8/3 に遮断した。ルールは2本で、/.env のようなスキャンパスと、記事を1件も取らずスキャンだけをしていた5つの名乗りを対象にした。入口で止めれば被害も計測の汚染も同時に消える、というつもりだった。

1か月分の集計を見たら、後半が外れていた。AI名乗りのスキャンは遮断後に145件から991件へ増え、遮断中の Perplexity-User は403のまま8日で595リクエスト計上されている。弾いた名前は捨てられて、スキャンは AmazonbotChatGPT-User という、本物が使っているのと同じ名前に移っていた。

遮断したリクエストは解析にそのまま残る

WAF を入れても解析の数字は綺麗になりません。Cloudflare の httpRequestsAdaptiveGroups はエッジが返した応答を集計するので、ブロックした分は403として計上され続ける。

Perplexity-User の推移が分かりやすい。ルール投入前は404で、投入後は403に変わっただけ。件数はむしろ増えている。

スナップショット403404状態
08-03(投入当日)14ルールはこの窓のほぼ全域で未適用
08-0824適用済み。スキャン側の量がまだ小さい
08-162970全て遮断、ただし全て計上
08-223110同上
08-285950同上
09-05370同上

遮断自体は効いている。404が消えて403になっているので、リクエストはオリジンに届いていない。それでも「AIクローラーからのアクセス」を UA で数える限り、この595件は数字の中に残る。WAF はオリジンを守る施策で、集計を直す施策ではない。 分けて考えるべきだった。

前提 — 入れた2本のルール

対象は Cloudflare Pages に置いた静的サイト(Astro)で、ゾーンは無料プラン。ルールは 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
)

contains を使っているのには理由がある。ゾーンには元から手動のルールがあったが starts_with(http.request.uri.path, "/.env") で書かれていて、ルート直下しか一致しなかった。実際に来ていたのは /server/.env(18回)や /admin/.env/app/.git/HEAD で、ルールがあるのに全部すり抜けていた。

2本目は UA で弾く。Cloudflare が検証できていない(not cf.client.bot)ことを条件に付けている。

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
) + ")"

この5つに絞ったのは、投入時点で記事の取得が0件・検証率0%だったから。CCBotPerplexityBot は検証率0%でも実在ページを200で取っていたので外している。学習データを集める側から切られると、露出を減らす方向にしかならない。

弾かれた側は、本物と同じ名前に移った

1か月で効いたのはルールの中身ではなく、相手の名乗り方だった。スキャン判定になったリクエストを名乗り別に並べるとこうなる。

名乗っている UA08-0808-1608-2208-2809-05
Perplexity-User(ルール2で遮断)414717338727
Amazonbot0672841190
ChatGPT-User2151181670
OAI-SearchBot91974250
GPTBot161872260
ClaudeBot131670270
PerplexityBot182167240
Google-Extended(ルール2で遮断)51870240
その他(CCBot / cohere-ai ほか)280000
合計11435799169927

遮断した Perplexity-User はその後も使われ続けていて、そこに弾いていない名前が上乗せされている。Amazonbot は67から284、ChatGPT-User は51から181。叩かれるパスの種類が増えたのではなく、同じ種類のスキャンが複数の名乗りに広がった。

やっかいなのは、この2つが本物と同じ名前だという点である。同じ8日間で Amazonbot は検証済み135・未検証489、ChatGPT-User は検証済み211・未検証336に割れていた。UA 一致で弾けば、記事を216件取りに来た本物の ChatGPT-User も一緒に落ちる。UA だけを条件にしたブロックリストは、偽装が本物の名前に移った時点で使えなくなる。

実在しない名前は話が別で、Google-Extended は HTTP リクエスト用の UA を持たないと Google のクローラー一覧 に明記されている。名乗った時点で全て偽装なので、一律に弾いても失うものがない。UA でのブロックが成立するのはこの型だけだと思う。

パスのブラックリストは後追いになる

1本目のルールは UA を見ないので、名乗りが何であれ一致すれば止まる。ただし一覧に書いたトークンの分しか止まらない。

08-28 のスナップショットには、AI名乗りのリクエストの404が300件残っていた。叩かれていたのは /secrets.yml/.zshrc/id_ecdsa/@fs/proc/self/environ あたりで、いずれもトークン一覧に無い。secrets.json は書いたが .yml は書いていないし、id_rsa は書いたが id_ecdsa は書いていない。

パスの一覧は、来たものを見てから足す運用になる。config.json も最初は eq "/config.json" の完全一致で書いていて、/runtime-config.json/api/runtime-config.json に抜けられた。サイトマップの実在164パスに config.json を含むものが無いことを確認してから contains に広げている。

止める対象を増やすほど、正当な配信物を巻き込む危険も増える。トークンを足すときはサイトマップの実在パスと突き合わせる、という手順にしておくと事故が減ると思う。

数字が落ちたのはルールのおかげではない

09-05 のスナップショットで、AI名乗りのスキャンは27件まで落ちた。合計リクエストも2,024件から688件に減り、ChatGPT-UserAmazonbotGPTBotOAI-SearchBot の検証率が揃って100%になった。数字だけ見れば対策が効いたように読める。

この8日間でルールは1文字も変えていない。効果が遅れて出る種類の設定でもない。

同じ期間に、ゾーン全体の403は2,754件から4,137件に増えている。スキャンそのものは止まっていない。AI の名前を使うのをやめただけ、と読むほうが辻褄が合う。断定はできないが、少なくとも「ルールを入れたから減った」と書くのは因果の取り違えになる。

施策を評価するときは、こちらが変更したタイミングと数字が動いたタイミングを並べて、ずれていたら原因を外に探す。今回はずれていた。

いま採っている二段構え

遮断と計測を別の層に分けた。

flowchart TD
  Req["AI名乗りのリクエスト"] --> WAF{"WAF ルールに一致するか"}
  WAF -->|"一致"| B["403 で遮断(オリジンに届かない)"]
  WAF -->|"不一致"| O["オリジンへ 200 / 404"]
  B --> A["解析にはどちらも残る"]
  O --> A
  A --> C{"取得側で分類"}
  C --> Y["検証済み × 実在パス = 読む数値"]
  C --> N["未検証 / スキャンパス = 捨てる"]

WAF に期待するのは、オリジンに届かせないことと、404 のログを増やさないことだけ。数字を綺麗にする役目は持たせない。

計測側は、verifiedBotCategory が空でないことと、パスがサイトマップに実在することの2軸で分ける。この分類は前回の記事に実装ごと書いた。今回のスキャン推移も、その分類の scan 列をスナップショット間で並べただけで出ている。

層を分けておくと、WAF のルールを触ったときに計測側の系列が動かない。逆も同じで、分類の条件を変えても遮断量は変わらない。1か月やってみて、この独立性がいちばんありがたかった。

まとめ

WAF を入れる価値そのものは残っています。オリジンに届かなくなるし、404 の山も減る。ただ、解析の数字が綺麗になると期待していた部分は外れた。ブロックしたリクエストは403として集計に残り続けるので、計測の汚染は取得側で分類して落とすしかない。

もうひとつ、UA を条件にしたブロックは寿命が短い。実在しない名前(Google-Extendedanthropic-ai)なら恒久的に弾けるが、本物と共有する名前に移られたら手が出ない。名前ではなく検証結果で判断する側に寄せるのが、たぶん唯一の逃げ道になる。

AI に読ませる側の整備は llms.txt を多言語で生成する記事 にまとめてある。

よくある質問

WAF で遮断したリクエストは解析から消える?

消えません。Cloudflare の GraphQL Analytics API(httpRequestsAdaptiveGroups)はエッジが返した応答を集計するので、ブロックしたリクエストは403として残ります。実測では、ルールで遮断している Perplexity-User が8日間で595リクエスト計上され、その内訳は403のみでした。遮断はオリジンに届かせない施策であって、集計から消す施策ではありません。

偽装UAを User-Agent 一致でブロックしていい?

実在しない名前だけに限れば有効です。Google-Extended のように公式が HTTP 用の UA を持たない名前は、名乗った時点で全て偽装なので一律に弾けます。一方 ChatGPT-UserAmazonbot は本物も同じ文字列で来るため、UA 一致で弾くと記事を取りに来た本物まで落ちます。実測でも、遮断した名前を避けてこの2つにスキャンが移りました。

スキャンパスのブラックリストはどこまで効く?

書いたトークンの分だけ効きます。/.env/wp- のような定番は止まりますが、/secrets.yml/.zshrc/id_ecdsa/@fs/proc/self/environ のように一覧に無いパスは素通りして404になります。8日間で404が300件残っていました。ブラックリストは新しいパスが来るたびに追記する後追いの運用になります。

スキャンが減ったらルールが効いたと読んでいい?

読めません。AI名乗りのスキャンは699件から27件に落ちましたが、この間ルールは変更していないので、原因はルールではなく送信側の都合です。同じ期間にゾーン全体の403は2,754件から4,137件に増えているので、スキャンそのものは続いていて、AIの名前を使うのをやめたと見るほうが実態に近いはずです。