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