author: hondazn

JS をゼロにしたつもりが、Cloudflare が配っていた


このブログは閲覧速さ最優先にしていて、クライアント JS をほぼ配りません。ビルド成果物を grep して <script>ゼロであることも確認していました。

ところが本番ページには JS が載っていました。Cloudflare が勝手入れていたものです。手元確かめ順に書きます。


気づいたきっかけ

記事表示モードを切り替える機能を作って、デプロイしたあと確認でした。切替 UI を持たないはずの /about に、スクリプトが1つある。手元成果物には無いものです。

# 手元のビルド
$ grep -ro '<script' dist/ | wc -l
0

# 本番
$ curl -s https://blog.jozo.beer/about/ | grep -o '<script' | wc -l
1

同じファイルを配っているつもりで、違うものが届いていました。

入っていたもの

2種類ありました。

注入対象中身
Email Address Obfuscationメールらしき文字を含むページ外部スクリプト email-decode.min.js を1本
JavaScript Detections全ページインラインおよそ 920 バイト

どちらも自分有効にした覚えがありません。片方サインアップ時自動有効になる設定でした。

1つめ — コード例が書き換えられていた

Email Address Obfuscation は、本文メールアドレスをリンク変え収集ボットから隠す機能です。親切機能見えます。

問題は、このブログに実在アドレス1つ無いことでした。全ソースを検索して一致したのは、別記事書いたこれだけです。

<code>github.com@evil.com</code> のような見た目の似たホストが弾かれます

攻撃例として出し作り物の文字で、<code>中にあります。Cloudflare はこれをメールアドレスと判定して、こう書き換えていました。

<a href="/cdn-cgi/l/email-protection" class="__cf_email__"
   data-cfemail="7a1d130e120f18541915173a1f0c131654191517">[email&#160;protected]</a>

コード例リンクになり、JS を切っている読者には [email protected]表示されます。攻撃例見せたい文章で、肝心文字読めない。

公式ドキュメント除外される場所一覧があります。<script> <noscript> <textarea> <xmp> <head>、それと HTML 属性

<code>入っていません。技術記事メールアドレスをコードとして見せると、必ず踏みます。

2つめ — ETag が剥がされていた

もう片方の JavaScript Detections は、ボット判定のためのビーコンです。全ページおよそ 920 バイト載ります。

公式記述読むと、2つ分かりました。

1つは、Bot Fight Mode を使っている間はこれを単独無効にできないこと。もう1つは、これだけでは何も強制しないこと。js_detection.passed使う WAF カスタムルールを別に作って初めて効きます。そのルールが無いなら、全ページに 920 バイト載るだけです。

効きめが無いのはまだいいとして、実害のほうが大きいものでした。同じ文書にこう書いてあります。

Enabling JavaScript Detections (JSD) will strip ETags from HTML responses where JSD is injected.

実測すると、そのとおりでした。

# HTML には ETag が無い
$ curl -sI https://blog.jozo.beer/blog/gh-img/ | grep -iE 'etag|cache-control'
cache-control: public, max-age=0, must-revalidate

# 同じサイトのアセットには付いている
$ curl -sI https://blog.jozo.beer/favicon.svg | grep -iE 'etag|cache-control'
cache-control: public, max-age=14400, must-revalidate
etag: "f46e90f224a1df98b3114a0c9c8956da"

HTML は max-age=0, must-revalidate なので、毎回サーバに問い合わせる設計です。ETag があれば 304 Not Modified済みます。剥がされていると、問い合わせるたびに本文丸ごと送り直させられます。

一番長い記事で gzip 後 35KB。再訪問のたびに、これを毎回です。速さ指標にしているサイトで、一番痛い失点でした。

止め方

ダッシュボードで個別切ることもできますが、JavaScript Detections は Bot Fight Mode ごと切ることになります。設定手元リポジトリに残らないのも気持ち悪い

両方とも Cache-Controlno-transform があれば適用されません。これは公式明記されています。Cloudflare Pages は _headers ファイル読むので、リポジトリの中完結します。

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

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

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

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

値は Pages の既定no-transform足しただけです。アセットにはわざと当てていません。/*一括すると、Pages が付ける max-age=14400上書きして画像キャッシュを潰します。

代償として Cloudflare の HTML・画像変換全部止まります。Rocket Loader、Polish、Mirage。このサイトは CSS をビルド時にインライン化し、画像も webp に変換済みなので、失うものはありませんでした。

結果

変更前変更後
全ページのビーコンおよそ 920 バイト0
外部スクリプト要求1本0
記事コード例[email protected]github.com@evil.com
HTML の ETag無し復活304確認
アセットのキャッシュmax-age=14400そのまま

調べはじめた動機は「JS を増やしたくない」でした。実際一番効いたのは、ETag が戻って再訪問304済むようになったことです。

手元の成果物を見ても、本番は分からない

今回教訓はこれに尽きます。

dist/<script>数えても、読者受け取るページのことは分かりません。CDN は配る途中で HTML を書き換えます。良かれ思って既定で。

検証実際の URL に curl投げるところまでやる。それだけの話でした。