研究紹介
Cloudflare が HTML に差し込むスクリプトは2種類ある。CSP はその片方を止めていた
· 読了まで約14分
目次
2 日前の Tell HN が 320 ポイントを超えている。R2 のバケットを自分のサブドメインから配信するためにネームサーバーを Cloudflare に向けたら、JavaScript を 1 行も置いていないサイトの HTML にアナリティクスのスクリプトが入っていた、という報告だ。
面白いのはスレッドの中身のほうで、集まったコメントの多くは、その場で自分のサイトを確認して結果を貼っている。そして答えが揃わない。同じ構成に見える人どうしで入っている・入っていないが分かれ、複数ドメインを持つ人は「一部だけ入っている」と言う。
うちは 2 つのゾーンに Pages と Workers static assets が混在している。8 ホストを全部測った結果、入るスクリプトは 2 種類、ビーコンに至る経路はそのうち 2 つあることがわかった。プロキシを通しているサイトの報告なら、この区別だけで食い違いは説明がつく。ついでに、2 種類のうち 1 つが自分の CSP に弾かれていることも見つかった。そのポリシーを入れた日から、訪問者のブラウザでは一度も動いていなかったことになる。
8 ホストを測ると、条件は 2 つの列に分かれた
本番に curl を打ち、static.cloudflareinsights.com/beacon.min.js と、/cdn-cgi/challenge-platform/scripts/jsd/ を読み込むインラインスクリプトの有無を数えた。
| ホスト | 配信元 | CSP | ビーコン | JS 検出 |
|---|---|---|---|---|
p4ni.com | Pages(独自ドメイン) | なし | あり | あり |
p4ni-2li.pages.dev | Pages(pages.dev) | なし | あり | なし |
ui.p4ni.com | Pages(独自ドメイン) | なし | なし | あり |
stats.p4ni.com | Pages(独自ドメイン) | なし | なし | あり |
ppaby.com | Pages(独自ドメイン) | あり | あり | なし |
yaso.ppaby.com | Pages(独自ドメイン) | なし | あり | なし |
astro.p4ni.com | Workers static assets | あり | なし | あり |
almanac.p4ni.com | Workers static assets | なし | なし | あり |
2 つの列を別々に読むと規則が出る。ビーコンの有無は Pages のプロジェクトに対応している。JS 検出の有無は ゾーンに対応していて、p4ni.com ゾーンのホストには全部入り、ppaby.com ゾーンには 1 つも入らず、どちらのゾーンにも属さない pages.dev には入らない。
「Cloudflare のプロキシを通しているかどうか」ではどちらも説明できない。8 ホストとも Cloudflare が配信している。
CSP の列については、先に 1 つ言っておくことがある。ppaby.com はポリシーを返していて、なおかつビーコンも入っている。そのポリシーが script-src に static.cloudflareinsights.com を書いているからだ。外部スクリプトは許可するかどうかの問題でしかなく、答えは allowlist に書ける。最後の節で、もう一方の注入がそういう種類の問題ではないことがわかる。
ビーコンの経路 1:Pages が自分で差し込んでいる
ビーコンはアカウントのトークンを持った defer 付きのスクリプトタグ 1 個だ。
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
data-cf-beacon='{"token": "b4ed0d7866324798b84e6ad1913b67b0"}'></script>
見つかった 4 ホストすべてで、出どころを名乗る HTML コメントに挟まれて出てくる。
<!-- Cloudflare Pages Analytics --><script defer src='...'></script><!-- Cloudflare Pages Analytics -->
このコメントが手がかりになる。Pages はプロキシとは別に、自分で配信時にビーコンを差し込んでいる。だから自分のゾーンの外にある p4ni-2li.pages.dev にも入るし、逆に入っているサイトと同じゾーンにある ui.p4ni.com には入らない。
Pages のこれはプロジェクト単位のオプトインで、場所は Workers & Pages → 対象プロジェクト → Metrics → Enable。測った 5 プロジェクトのうち 3 つで有効になっていたのは、過去に自分で有効化して忘れていたからだ。
ビーコンの経路 2:無料ゾーンは 2025 年 10 月から既定オン
Tell HN の人が踏んだのは同じスクリプトに至る別の経路で、こちらは再現できなかった。Cloudflare は 2025 年 9 月に告知し、10 月 15 日から無料ゾーンの Web Analytics を既定で有効にしている。エッジでプロキシ通過中の HTML にビーコンを差し込むやり方で、有料プランは従来どおりオプトインのままだ。
つまり無料ドメインをオレンジクラウドにした時点で、頼んでいないアナリティクスが配信される。しかも切るためのトグルは Web Analytics の画面にあり、直前まで触っていた DNS の設定画面の近くには無い。一度切れば勝手に戻されることはない、と Cloudflare 自身が約束している。「一度無効化したら、こちらから再度有効化することはありません」。
はっきり書いておくと、この経路は自分では観測していない。うちのアカウントで見つかった 4 つのビーコンは全部 Pages 由来でコメントに挟まれていたので、エッジ経由のものがマークアップ上どう見えるかは言えない。コメントの無いビーコンが出てきたなら、出どころはたぶんこちらだ。
エッジ側の注入が止まる条件は FAQ に 2 つ書かれている。レスポンスが妥当な HTML であること、そして Cache-Control: public, no-transform が付いていないこと。no-transform が付いているとプロキシはペイロードを書き換えられない。
ダッシュボードではなく自分のコードから引ける唯一のレバーがこれで、Workers static assets なら置き場所は _headers ファイルになる。Pages 経由の注入に対して効くかは試していない。おそらく効かない。Pages はプロキシの書き換えを通らず自前で差し込んでいるからだ。
Workers static assets の 2 サイトにはビーコンが入っていない。Pages サイトに入っているのと同じゾーンでの話だ。ただし、これが何を証明しているかは慎重に書きたい。ゾーンレベルの経路自体をどちらのゾーンでも観測していないので、「Workers のレスポンスがエッジの書き換え対象外」なのか「そもそもこのゾーンでエッジの書き換えが動いていない」のかを区別できない。言えるのは、Pages の注入がここには届かないこと、そして Workers で RUM を取りたいなら自分でスクリプトを置くことになる、という 2 つだけだ。Pages と Workers の比較にもう 1 項目増えたが、この差はどちらの製品ドキュメントにも書かれていない。
Bot Fight Mode の JS 検出は無料プランでは切れない
スレッドで誰も触れていなかったが、HTML への踏み込み方はこちらのほうが深い。読み込み元を指す src は無く、コードそのものが書き込まれている。外側のラッパーが 1×1 の非表示 iframe を作り、その iframe のドキュメントに向けて 2 つ目のスクリプトを書き込む構造になっている。
(function(){
function c(){
var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);
if(b){var d=b.createElement('script');
d.innerHTML="window.__CF$cv$params={r:'a2c597d03c31c0b4',t:'MTc4NjkzNzM1MQ=='};"+
"var a=document.createElement('script');"+
"a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';…";
b.getElementsByTagName('head')[0].appendChild(d)}}
if(document.body){var a=document.createElement('iframe');
a.height=1;a.width=1;a.style.visibility='hidden';document.body.appendChild(a); … }
})();
正体は Bot Fight Mode の一部である JavaScript Detections で、ゾーン単位で効く。p4ni.com はオン、ppaby.com はオフ、pages.dev には無い。ビーコンと違い、Workers static assets のレスポンスにも付く。自分の Worker が返した HTML の末尾に追加されて返ってくる。
無料枠の Bot Fight Mode では、JavaScript Detections は「自動的に有効になり、無効にできない」と公式が書いている。トグルは存在しない。止めたければゾーンごと Bot Fight Mode を切るしかない。Super Bot Fight Mode と Enterprise には本物のスイッチがある。
ハッシュ方式の CSP は、このスクリプトを原理的に許可できない
astro.p4ni.com は 'unsafe-inline' を含まないポリシーを返している。値は自前のインラインスクリプトのハッシュから生成している。
script-src 'self' 'sha256-0at8MBhV/…' 'sha256-QGOI0zA5LP…' … https://www.googletagmanager.com
Cloudflare はこのヘッダーが確定した後ろでラッパーを追加する。ブラウザは CSP に書かれたとおりに振る舞う。
Executing inline script violates the following Content Security Policy directive
'script-src 'self' 'sha256-…' …'. Either the 'unsafe-inline' keyword, a hash
('sha256-BzqoJdU21HVAYQREq/sA3S0K+mfmYqOMdKl9oOK6IH0='), or a nonce is required
to enable inline execution. The action has been blocked.
Chrome は親切にハッシュ値まで教えてくれるが、これは使えない。ラッパーの中身をもう一度見ると、r: はそのリクエストの CF-Ray で、t: はタイムスタンプだ。同じ URL に 3 回連続でリクエストするとこうなる。
ray=a2c59c2f2d92e07a ts=MTc4NjkzNzUzMA== sha256-PzYAl89jKGJxYnK3RBTdOBwEoRJ6IjqugAiWRQ/HO9c=
ray=a2c59c3098d0e05a ts=MTc4NjkzNzUzMA== sha256-pm21k32xDqdTnabwlxqMt+ptI9f+CyC6UQ2MhZbv7sY=
ray=a2c59c31fb024efb ts=MTc4NjkzNzUzMQ== sha256-P/6WIZH1ogc/zbiwpSMvhvdJ/oFRb53PQSj5rhUE6no=
3 回で 3 つとも違う。レスポンスごとに中身が変わるのだから、静的なハッシュではこのスクリプトを許可できない。最初の表にあった ppaby.com とは別の問題だ。あちらはホスト名を script-src に書けば済んだが、バイト列が毎回変わるインラインスクリプトを通す allowlist の書き方は存在しない。そして何も知らせてくれない。ページは普通に表示され、デプロイも通る。ポリシーを守るブラウザの中でだけ、この機能が黙って止まっている。
Cloudflare の CSP に関する案内は「/cdn-cgi/challenge-platform/ 配下を許可し、script-src 'self' を保て」と言っている。うちのポリシーは両方とも満たしている。見落としやすいのはそこで、外部スクリプトの取得自体は許可されている。その取得が起きないだけだ。きっかけになるインラインのラッパーが先に弾かれる。
公式が示す逃げ道は nonce のほうだ。Cloudflare は CSP レスポンスヘッダーを解析し、自分が注入するスクリプトに nonce を付けてくれる。ただし <meta> タグで指定した nonce には対応しない。
ハッシュと nonce を比較した記事を書いたときには、この論拠を持っていなかった。ただし見た目ほど強い論拠でもない。完全な静的サイトで nonce を発行するには HTML の前段に Worker を置くしかなく、あの記事の主眼はまさに、その Worker がドキュメント内のインラインスクリプトなら何にでも同じ nonce を押してしまう点にあった。本来そこに無いはずのスクリプトも含めて、だ。bot 検出を買い戻すために、注入されたスクリプトを止める仕組みのほうを緩めるのでは、順序が逆になる。
自分のサイトを確かめる
ダッシュボードを開かなくても 1 行でわかる。
curl -s https://example.com/ | grep -o -e 'cloudflareinsights[^"'"'"']*' -e 'challenge-platform[^"'"'"']*'
返ってきたものを、この一覧に突き合わせる。
Cloudflare Pages Analyticsコメント付きのビーコン。Pages のプロジェクト単位。Workers & Pages → プロジェクト → Metrics で切る- コメントの無いビーコン。ゾーンレベルの RUM である可能性が高い。2025 年 10 月から無料ゾーンは既定でオン。Web Analytics の画面で無効化すれば設定は残る。自分のコード側で止めたいなら
Cache-Control: public, no-transformを返す - インラインの
__CF$cv$params。JavaScript Detections。Bot Fight Mode ではトグルが無い。Bot Fight Mode ごと切るか、プランを上げるか、受け入れるか
JS 検出だけが返ってきてビーコンが無いなら、Workers static assets の可能性が高い。Pages の注入はそこまで届かない。なおこの一覧で全部ではない。Email Address Obfuscation はメールアドレスを /cdn-cgi/l/email-protection のリンクに書き換えてデコード用のスクリプトを足すが、うちの HTML にはメールアドレスが 1 つも無いので発火せず、今回の計測には入っていない。
そのうえで自分のトップページをブラウザで開き、コンソールを見てほしい。厳格な CSP を運用しているなら、探すべきは足りないスクリプトではない。そのポリシーをデプロイした日からずっと、全訪問者に配り続けていたエラーのほうだ。CSP を実際に適用するブラウザで一度でも自分のサイトを開いていれば、初日に見つかっていた。
うちはブロックしたままにする。ただしこれは事故ではなく判断になった。測る意味はそこにある。