0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare Pagesで無効にしたはずのbeacon.min.jsが注入される問題を追った — curlの罠、CSP、no-transform、正しい止め方

0
Posted at

Cloudflare Pagesで公開している静的サイトのConsoleに、突然CSPエラーが出るようになりました。

Refused to load the script
'https://static.cloudflareinsights.com/beacon.min.js/...'
because it violates the following Content Security Policy directive: ...

自分のソースコードには、そんな<script>を書いていません。Cloudflare Web Analyticsも無効にしてあるはずでした。

調べてみると、Cloudflareの配信レイヤーがHTMLへbeacon.min.jsを自動注入していました。今回の環境では、注入されたのはContent-Type: text/htmlのGETレスポンスです。さらに、普通にcurlしただけでは注入が見えず、回避策としてCache-Control: no-transformを付けると今度はHTMLのBrotli圧縮まで失われる、という複数の罠が重なっていました。

最終的には、CloudflareのZone側でWeb Analyticsをいったん有効化してから明示的にDisable/Deleteすることで、beaconを止めながらBrotli圧縮を維持できました。

この記事では、その切り分けと解決までを実測値つきでまとめます。

TL;DR

  • Cloudflareは2025年10月15日から、FreeドメインのWeb Analyticsを既定で有効化している
  • PagesのMetricsが無効、アカウント直下のWeb Analyticsサイト一覧が空でも、Zone側のRUM設定が残っている場合がある
  • curl既定のAccept: */*では注入されず、実ブラウザ相当のAccept: text/htmlでだけ再現することがある
  • Cache-Control: no-transformでも自動注入は止まるが、CloudflareによるHTML圧縮も止まる
  • Zoneの「Analytics & Logs → Web Analytics」で、いったん設定を実体化してからDisable/Deleteすると解決した
  • それでも止まらない場合は、Configuration Rulesの「Disable Real User Monitoring」でWeb Analytics側の設定を上書きできる
  • 解決後はbeacon.min.jsが消え、Brotli圧縮も維持できた

発端:ソースにないスクリプトがCSPに拒否される

対象サイトでは、外部スクリプトの読み込み先をCSPのscript-srcで制限しています。static.cloudflareinsights.comは許可していないため、Cloudflareのbeaconが注入されるとブラウザが読み込みを拒否します。

配信されたHTMLの末尾には、次のようなタグが追加されていました。tokenは伏せています。

<script
  type="module"
  src="https://static.cloudflareinsights.com/beacon.min.js/..."
  integrity="sha512-..."
  data-cf-beacon='{"version":"2024.11.0","token":"...","r":1}'
  crossorigin="anonymous">
</script>

これはビルド前のHTMLにも、Cloudflare Pagesへアップロードしたdist/にも存在しません。CloudflareのエッジでHTMLレスポンスが書き換えられています。

今回のCSPでは、最初のbeacon.min.js自体がscript-srcで拒否されました。ブラウザは外部スクリプトの取得リクエスト自体を発行しないため、static.cloudflareinsights.comへIPアドレスを含む通信は発生せず、beaconのJavaScriptも実行されていません。当然、計測データも送信されていません。ただし、次の問題は残ります。

  • 配信HTMLが意図せず変更されている
  • 全ページのConsoleにCSP違反が出る
  • 本当に対応すべきCSPエラーがノイズに埋もれる
  • 「Cloudflare Web Analyticsを使用していない」というサイト側の方針と配信物が食い違う

ここでCSPへstatic.cloudflareinsights.comを追加するとエラーは消えますが、それはbeaconを許可して動かす対処です。Web Analyticsを使わないことが目的なら、注入元の設定を止める必要があります。

最初の罠:普通のcurlでは「注入なし」に見える

最初は次のコマンドで本番HTMLを確認していました。

curl -sS https://benri.dev/ | grep -c 'beacon.min.js'

結果は0です。ところが、実ブラウザで開くとCSPエラーが再現します。

原因はAcceptヘッダーでした。同じURL・同じGETでも、今回の環境ではAcceptだけでCloudflareの注入有無が次のように変わりました。

Accept: text/html  → beacon.min.jsが注入される
Accept: */*        → 注入されない

curlの既定値はAccept: */*です。つまり、最初の検証コマンドは実ブラウザと異なるレスポンスを見ていました。

実ブラウザ相当で確認するには、Accept: text/htmlを明示します。

curl -sS \
  -H 'Accept: text/html' \
  https://benri.dev/ |
  grep -c 'beacon.min.js'

圧縮されたレスポンスをcurl側で展開して確認するなら、--compressedも付けます。

curl -sS --compressed \
  -H 'Accept: text/html' \
  https://benri.dev/ |
  grep -c 'beacon.min.js'

今回のサイトでは、修正前に1、修正後に0になりました。

これはCloudflareがAccept: text/htmlで常に同じ挙動を保証している、という意味ではありません。少なくとも「curlで0だったからブラウザでも注入されていない」とは限らないため、HTML改変を確認するときはブラウザ相当のリクエストヘッダーを付けるべき、という教訓です。

2つ目の罠:設定画面では無効に見える

Cloudflare Dashboardには、Web Analyticsに関係する入口が複数あります。

今回、最初に確認したのは次の2か所でした。

  1. Workers & Pages → 対象Pagesプロジェクト → Metrics → Web Analytics
  2. アカウント直下 → Web Analytics → Sites

確認結果は次のとおりです。

  • Pagesプロジェクト側:無効
  • アカウント側:登録サイト0件

これだけを見ると、Cloudflare Web Analyticsは完全に無効に見えます。それでもHTMLには、特定のdata-cf-beacon tokenを持つタグが注入され続けていました。

見落としていたのは、Pagesプロジェクトでもアカウント直下でもなく、ドメイン(Zone)側の設定です。

Domains
└─ benri.dev
   └─ Analytics & Logs
      └─ Web Analytics

Cloudflareは2025年10月15日から、FreeドメインのWeb Analyticsを既定で有効化すると発表しています。

さらに公式のopt-out手順は少し直感に反します。無効にしたいのに、まずEnable GloballyまたはExclude EUを選び、設定を有効化・実体化した後でManage RUM SettingsからDisableまたはDeleteします。

UI上で「データなし」「RUMを有効化」と表示されていても、それだけでエッジ側の自動注入設定が存在しないとは判断できません。

回り道:no-transformなら確実に止まる

実は、かつて同じ自動注入を止めるため、HTMLへ次のレスポンスヘッダーを付けていました。圧縮への影響を理由にいったん外していましたが、今回、Zone側の原因を特定するまでの確実な回避策として再評価しました。

Cache-Control: public, max-age=0, must-revalidate, no-transform

Cloudflare Pagesの_headersなら、例えば次のようにHTMLのパスを個別指定できます。

/
  Cache-Control: public, max-age=0, must-revalidate, no-transform
/tools/*
  Cache-Control: public, max-age=0, must-revalidate, no-transform
/articles/*
  Cache-Control: public, max-age=0, must-revalidate, no-transform
/en/*
  Cache-Control: public, max-age=0, must-revalidate, no-transform

no-transformは、中間者にレスポンス本文を変換しないよう指示するCache-Controlディレクティブです。Cloudflare公式FAQにも、Cache-Control: public, no-transformがあるとプロキシが元のペイロードを変更できず、Web Analyticsのbeaconを自動注入できないと説明されています。今回の実測では、public, max-age=0, must-revalidate, no-transformでも停止しました。must-revalidateを併記してもno-transformは有効です。

実際、これでbeacon.min.jsは消えました。

しかし副作用があります。Cloudflareは、オリジンから未圧縮で届いたレスポンスにno-transformがある場合、Brotli/Gzip圧縮も適用しません。Cloudflare Pages上のHTMLも無圧縮になりました。

今回のトップページでは、次の差が出ました。

Brotli圧縮あり: 16,367B
no-transform:   90,048B

転送量は約5.4倍です。

JS/CSSなどの/assets/*no-transformを付けなければ、それらの圧縮・キャッシュは維持できます。それでもHTML本文が大きいサイトでは、no-transformの代償は無視できません。

ここで_headersの対象を安易に/*へ広げてはいけません。Cloudflare Pagesは、リクエストに一致したヘッダールールをすべて適用します。/*/assets/*が両方存在すると、アセットには両方のCache-Controlが付き、長期キャッシュ用の設定とno-transform付きの設定が重複します。

逆に、上のようにHTMLの既知パスだけを列挙すると、どのルールにも一致しないURLにはno-transformが付きません。存在しないURLへ返される404 HTMLにはbeaconが残る可能性があります。つまり、no-transformは「指定したHTMLでは確実に止まる」回避策ですが、サイト全体を漏れなく覆いながらアセットを除外するには、パス設計と404まで含めた確認が必要です。

minifyも有効ですが、minifyと転送圧縮は別物です。HTMLのコメントや空白を削っても、自然言語の本文やJSON-LDをBrotliで圧縮したときの削減量には届きません。

そのため、no-transformは「注入を確実に止める最終手段」にはなりますが、まずCloudflare側の設定を正すべきです。

解決手順:Zone側で一度有効化してからDisable/Delete

今回、実際に解決した手順です。Cloudflare DashboardのUI名は今後変わる可能性があります。

  1. Cloudflare DashboardでDomainsを開く
  2. 対象ドメインを選択する
  3. Analytics & LogsWeb Analyticsを開く
  4. Exclude EUまたはEnable Globallyを選択する
  5. 設定が表示されたらManage RUM Settingsを開く
  6. Disableを選ぶ。完全に削除する場合はAdvanced OptionsDelete
  7. 必要に応じてCachingConfigurationPurge Everything
  8. 数分待って、Accept: text/html付きで再確認する

一時的に有効化するのが気になる場合、対象サイトのCSPがbeacon.min.jsを拒否していれば、その間もスクリプトは実行されません。ただし、CSPを設定していないサイトでは、一時的にbeaconが取得・実行され、計測が走る可能性があります。有効化したまま放置せず、続けてDisable/Deleteまで行ってください。Enable Globallyより収集範囲の狭いExclude EUを選ぶのが無難です。

Cloudflare公式ブログにはAPIで設定を作成してから無効化・削除する方法も掲載されています。APIキーの取り扱いが必要になるため、通常はDashboard操作の方が簡単です。

なお、キャッシュ全削除はCloudflare公式のopt-out手順では必須とされていません。今回は反映確認を明確にするため実施しました。同じ症状を報告した英語圏の事例でも、Delete後にPurge Everythingを行って解消しています。

それでも止まらない場合:Configuration Rulesで強制停止

今回の環境ではZone側のDisable/Deleteで解決したため、次の方法は使用していません。ただしCloudflareには、Web Analytics側の設定より優先される公式の停止手段があります。

  1. 対象ZoneのRulesConfiguration Rulesを開く
  2. 全リクエスト、または対象ホスト名に一致するルールを作る
  3. 設定のDisable Real User Monitoringを有効にする
  4. ルールをデプロイし、Accept: text/html付きのGETで再確認する

Cloudflare公式ドキュメントでは、Configuration RulesがWeb Analyticsのルールより優先されると明記されています。つまりWeb Analytics側で計測が有効になっても、同じリクエストに対してRUMを無効化するConfiguration Ruleが一致すれば、無効化側が勝ちます。

Configuration RulesはFreeプランでも利用でき、上限は10ルールです。Zone側のDisable/Deleteで止まらない場合は、圧縮を失うno-transformへ進む前にこちらを試すのが適切です。

解決結果

修正前、no-transform適用時、Zone設定修正後を比較すると次のようになりました。

状態 beacon注入 HTML圧縮 トップページ転送量
Zone設定修正前 あり Brotli 16,638B
no-transform適用 なし なし 90,048B
ZoneでDisable/Delete後 なし Brotli 16,367B

Zone設定修正前と修正後は、同条件のBrotli圧縮レスポンスで271Bの差がありました。これはbeaconタグが入っていた状態と消えた状態の、圧縮後の転送量差です。

転送量は各状態を確認した時点の実測値です。デプロイ内容やCloudflare側の変換によって変動するため、絶対値ではなく3状態の差を見るための値として掲載しています。

最終確認では、次のページすべてでbeaconが0になりました。

  • /
  • /tools/qr-generator
  • /en/
  • /articles/
  • /privacy
  • 存在しないURL(404 HTML)

同時に、Content-Encoding: brも維持できています。no-transformへ戻す必要はなくなりました。

再発確認用コマンド

beacon自動注入の有無

curl -sS --compressed \
  -H 'Accept: text/html' \
  https://benri.dev/ |
  grep -c 'beacon.min.js'

期待値:

0

Brotli圧縮の確認

HEADではなくGETでレスポンスヘッダーを確認する例です。HEADには転送されるレスポンス本文がないため、実際に配信されるHTML本文へ圧縮が適用されることを確認するにはGETを使います。

curl -sS \
  -D - \
  -o /dev/null \
  -H 'Accept: text/html' \
  -H 'Accept-Encoding: br' \
  https://benri.dev/ |
  grep -i '^content-encoding:'

期待値:

content-encoding: br

実際の転送量

ここでは--compressedを付けません。curl側で展開せず、実際にネットワークで受信した圧縮後のバイト数をsize_downloadで確認するためです。

curl -sS \
  -o /dev/null \
  -H 'Accept: text/html' \
  -H 'Accept-Encoding: br' \
  -w '%{size_download}\n' \
  https://benri.dev/

複数ページを確認

トップページだけでなく、言語別ページ、記事、プライバシーページなど代表的なHTMLを確認します。

for path in / /tools/qr-generator /en/ /articles/ /privacy /this-page-does-not-exist; do
  count=$(
    curl -sS --compressed \
      -H 'Accept: text/html' \
      "https://benri.dev${path}" |
      grep -c 'beacon.min.js'
  )
  printf '%-24s beacon=%s\n' "$path" "$count"
done

今回の教訓

1. CDN配信後のHTMLも検証する

リポジトリやビルド成果物が正しくても、CDNのエッジ機能がHTMLを書き換えることがあります。CSP、解析タグ、Rocket Loader、JavaScript Detectionsなどを使う環境では、オリジンのファイルだけでなく最終レスポンスも確認する必要があります。

2. curlの既定ヘッダーをブラウザ相当だと思わない

今回最大の見落としはAccept: */*でした。レスポンスがリクエストヘッダーで変わる機能を検証するときは、実ブラウザのNetworkパネルとcurlの差を確認します。

3. CSPエラーを安易に許可リスト追加で消さない

CSPが外部スクリプトを拒否したとき、許可リストへドメインを追加すればConsoleは静かになります。しかし、そのスクリプトを本当に動かしたいのかを先に判断すべきです。今回はCSPが、意図しないHTML改変を発見するセンサーとして機能しました。

4. no-transformは圧縮とセットで評価する

no-transformはHTML改変を止める強力な手段ですが、Cloudflareの圧縮も止めます。セキュリティ・プライバシー上の要求と、転送量・表示速度の両方を実測して判断する必要があります。

5. Cloudflareの設定スコープを分けて考える

Pagesプロジェクト、アカウント、Zoneには似た名前のWeb Analytics設定があります。一つの画面が無効でも、別スコープの設定が有効とは限りません。特にFreeドメインの既定有効化後は、Zone側も確認対象です。

さらにConfiguration RulesはWeb Analytics側のルールより優先されます。設定の入口を探すだけでなく、最後にどのルールが勝つのかまで分けて考える必要があります。

参考資料

最後に:この問題を見つけたBenri.devについて

今回の検証は、Benri.dev(べんり箱)の本番環境で行いました。

Benri.devは、文字数カウント、JSON整形、QRコード生成、画像・PDF処理、各種計算など、日常や開発で少しだけ必要になる作業をまとめたWebツール集です。2026年7月時点で139ツールを公開しており、日本語版と英語版を用意しています。

登録不要・無料で、各ツールへ入力したテキストやファイルの処理は100%ブラウザ内で完結します。入力データをツール処理のためにサーバーへ送信しないと明記している以上、今回のような意図しない外部スクリプト参照がないか、実際の配信HTMLとプライバシー方針が食い違っていないかも継続的に確認しています。

ブラウザだけで手早く済ませたい作業があれば、Benri.devを試してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?