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 を投げるところまでやる。それだけの話でした。