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?

ページキャッシュ自作の本体は「返さない判定」だった。除外条件と判定順の完全リスト【WordPress・保存版】

0
Posted at

ページキャッシュを自作しはじめたとき、コードの大半は「速く返す部分」になると思っていました。

実際に書き上がってみると、逆でした。速く返す部分は、ファイルを読んで exit するだけで、数行です。長かったのは、返してはいけない場合を判定する部分でした。行数でいうと、こちらが本体です。

そして事故が起きるのも、こちらです。キャッシュのバグは、たいてい「他人のページを他人に見せる」形で出ます。ログイン中の管理画面が、ログアウト状態の訪問者に配られる。カートの中身が別の人に見える。速さの調整をミスしても遅くなるだけですが、除外の判定をミスすると、情報が漏れます。

自作したキャッシュプラグインをWordPress.orgで公開しているので、その除外条件を全部、判定順に並べておきます。移植するときのリファレンスとして使えるように、それぞれ理由も書きました。

実物はここで読めます。

判定は、2段階に分かれる

先に全体の形を。判定するタイミングは2つあります。

第1段階は、WordPressが読み込まれる前です。リクエストの内容だけで判断できるものを、ここで弾きます。キャッシュを返す前でもあるので、ここが速さに直結します。

第2段階は、レンダリングが終わったあとです。出力バッファのコールバックで、できあがったHTMLとレスポンスヘッダを見て、保存してよいかを判断します。ページを作らないと分からないことは、ここでしか判定できません。

同じ判定を両方に置く必要はありませんが、どちらに置けるかは考える価値があります。 第1段階で弾けるものを第2段階に回すと、無駄にページを生成することになります。逆に、第2段階でしか分からないものを第1段階に置こうとすると、判定が推測になります。

第1段階:WordPressロード前の除外

上から順に、実装している判定です。順序にも意味があるので、その順で書きます。

1. リクエストメソッド

GETHEAD 以外は、全部除外します。POST はもちろん、PUTDELETE も。

$method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
if ($method !== 'GET' && $method !== 'HEAD') {
    return; // キャッシュに関与しない
}

理由は、副作用のあるリクエストにキャッシュを返しても意味がなく、保存する対象でもないからです。フォーム送信の結果をキャッシュしたら、次の人が他人の送信結果を見ます。

いちばん最初に置いているのは、判定が最も安いからです。

2. Authorization ヘッダ

Authorization ヘッダが付いていたら除外します。Basic、Bearer、Digest、どれでも。

if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
    return;
}

認証を通したリクエストは、その人向けのレスポンスである可能性が高い。ヘッダの中身を見て判断するより、あったら諦めるほうが安全でした。

3. Host の許可リスト(fail-close)

リクエストの Host が、設定した許可リストに含まれていなければ除外します。

$host = isset($_SERVER['HTTP_HOST']) ? strtolower($_SERVER['HTTP_HOST']) : '';
if (!in_array($host, $allowed_hosts, true)) {
    return; // 許可リストに無ければキャッシュしない
}

ここは fail-close にしています。許可リストが未設定だったり、判定に失敗した場合も、キャッシュしません。

Host はキャッシュキーのディレクトリ部分に使うので、任意の値を通すと、攻撃者が指定した Host でディレクトリが作られます。キャッシュ汚染の入口になるので、知っているホストだけを通します。

4. WordPress のシステムURL

管理画面やログイン、cron、XML-RPC は除外します。

// 境界のアンカーを付ける(部分一致にしない)
$pattern = '#(^|/)(wp-admin(/|$)|wp-login\.php|wp-cron\.php|xmlrpc\.php)#';
if (preg_match($pattern, $request_uri)) {
    return;
}

ここで、単純な部分一致にしないことが要ります。strpos($uri, 'wp-admin') !== false のように書くと、/blog/wp-admin-guide/ のような普通の記事URLまで巻き込みます。

除外条件は、緩いと情報漏洩になりますが、雑だと正常なページがキャッシュされません。どちらの失敗も起きるので、境界のアンカーを入れています。

5. EC のパス

WooCommerce を使っているサイトでは、次のパスを除外します。

cart / checkout / my-account / wc-api / wc-ajax / add-to-cart

$ec_patterns = ['/cart', '/checkout', '/my-account', 'wc-api', 'wc-ajax', 'add-to-cart'];

どれも、ユーザーごとに内容が変わる場所です。カートの中身をキャッシュして配ってしまうのは、キャッシュプラグインの典型的な事故なので、パスの段階で落とします。

自分のプラグインは EC 専用ではありませんが、この判定は入れています。使う人がECを入れているかどうかは、こちらから分からないので。

6. バイパス cookie

次の cookie があるリクエストは除外します。

  • wordpress_logged_in_(ログイン中キャッシュを無効にしている場合)
  • woocommerce_cart_hash
  • wp_woocommerce_session_
  • woocommerce_items_in_cart
  • comment_author_
  • wp-postpass_
$bypass_prefixes = [
    'woocommerce_cart_hash',
    'wp_woocommerce_session_',
    'woocommerce_items_in_cart',
    'comment_author_',
    'wp-postpass_',
];
if (!$cache_logged_in) {
    $bypass_prefixes[] = 'wordpress_logged_in_';
}

foreach (array_keys($_COOKIE) as $name) {
    foreach ($bypass_prefixes as $prefix) {
        if (strpos($name, $prefix) === 0) {
            return;
        }
    }
}

前方一致で見ています。 これらの cookie はハッシュ付きの名前になるので、完全一致では拾えません。

comment_author_ を入れているのは、コメント投稿後の「あなたの名前」が入った表示をキャッシュしないためです。wp-postpass_ は、パスワード保護記事を通過した状態を示すので、その状態でのHTMLを保存すると、パスワードを知らない人に中身が配られます。

7. HTML ではない拡張子

.xml .json .css .js .map .txt などは除外します。

if (preg_match('#\.(xml|json|css|js|map|txt|xsl|ico|png|jpe?g|gif|svg|webp)$#i', $path)) {
    return;
}

ページキャッシュはHTMLを対象にしているので、それ以外は扱いません。フィードやサイトマップをHTMLと同じ扱いで保存すると、Content-Type がずれます。

8. クエリ文字列(許可リスト方式)

許可していないクエリパラメータが付いていたら除外します。そして、許可したものだけをキャッシュキーに含めます。

// 許可リスト。既定で無視するもの(キーに含めず、同一キーに集約)
$ignored = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
            'fbclid', 'gclid', 'ref'];
// キーに含めるもの(ページングなど)
$allowed = ['page', 'paged', 'p'];

拒否リストではなく許可リストにしたのには理由があります。拒否リストだと、知らないパラメータが来たときにキャッシュが増え続けます。 ?xyz=1 ?xyz=2 と適当なパラメータを付けて叩かれると、その数だけファイルが作られる。ディスクを埋められます。

そして utm_* のような計測パラメータは、無視して同じキーに集約します。これをやらないと、SNSからの流入で ?utm_source=... が付くたびに別のキャッシュになって、ヒット率が落ちます。

9. 設定による除外(正規表現、fail-close)

利用者が設定できる除外を、4種類持っています。URI、cookie、User-Agent、referrer。それぞれ正規表現です。

// 正規表現のコンパイルに失敗したら、キャッシュしない側に倒す
$compiled = @preg_match($user_pattern, $subject);
if ($compiled === false) {
    return; // パターンが壊れている → 安全側
}
if ($compiled === 1) {
    return; // マッチ → 除外
}

ここも fail-close です。利用者が書いた正規表現が壊れていたとき、判定できないので、キャッシュしません。

エラーを無視して「マッチしなかった」として扱うと、除外したいページがキャッシュされます。設定を書いた人は除外したつもりなので、いちばんまずい形の失敗になります。

10. モバイル判定

モバイルのキャッシュを無効にしている設定なら、モバイルからのリクエストを除外します。有効かつ「モバイルを分離」する設定なら、キーに -mobile を付けて別のキャッシュにします。

判定はUAの正規表現です。ここは完璧にはならないので、分離するかどうかを設定で選べるようにしました。テーマがレスポンシブなら分離は不要ですし、UAで出し分けているなら必須です。

11. スキーム不一致

http と https のスキームが、サイト設定と食い違っている場合は除外します。

WordPress内部でリダイレクトが起きるケースを保護するためです。リダイレクト前の状態をキャッシュしてしまうと、リダイレクトループのような形で固定されることがあります。

第2段階:レンダリング後の除外

ページが作られたあと、出力バッファのコールバックで判定します。

1. HTTP ステータス

200 以外は保存しません。404 だけ、設定が有効なときに 404-index として別に保存します。

$status = http_response_code();
if ($status !== 200) {
    if (!($status === 404 && $cache_404)) {
        return $html; // 保存せずに返す
    }
}

404 をキャッシュできるようにしているのは、存在しないURLへの大量アクセスで毎回WordPressを起動させられるのを防ぐためです。ただ、既定では無効にしています。404の内容が動的なサイトもあるので。

2. DONOTCACHEPAGE 定数

if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) {
    return $html;
}

他のプラグインやテーマが「このページはキャッシュするな」と表明するための、事実上の共通規約です。自分のプラグインの都合ではなく、エコシステムの作法として見ています。

3. Set-Cookie が付いている

レスポンスヘッダに Set-Cookie があれば保存しません。

foreach (headers_list() as $header) {
    if (stripos($header, 'Set-Cookie:') === 0) {
        return $html;
    }
}

これは、いちばん怖い判定だと思っています。 Set-Cookie が付いているレスポンスは、その人に固有の状態を作っている途中です。それをキャッシュして配ると、他人のセッションを渡すことになりかねません。

自分はこの判定を、実装の後から足しました。入れる前にログイン直後のページが配られていたら、と考えると、いまでもぞっとします。

4. Cache-Control

privateno-store があれば保存しません。no-cache は許可します。

// no-cache は「使う前に再検証しろ」であって「保存するな」ではない
if (preg_match('/(private|no-store)/i', $cache_control)) {
    return $html;
}

ここは間違えやすいところです。no-cache を「保存禁止」として扱うと、キャッシュできるページをかなり取りこぼします。保存を禁止しているのは no-store、共有キャッシュを禁止しているのが private です。

5. Vary

VaryAccept-Encoding 以外を含んでいたら保存しません。

// Accept-Encoding だけは gzip/plain の2系統で持っているので許容
if ($vary !== '' && strtolower(trim($vary)) !== 'accept-encoding') {
    return $html;
}

Vary は「このヘッダによって内容が変わる」という宣言です。URLベースのキーで保存する仕組みでは、ヘッダ違いを区別できません。Vary: Accept-Language が付いたページをキャッシュすると、日本語の人に英語のページを返します。

対応できない Vary は、保存しない。 これが安全側でした。

6. HTML の完全性

</html> が含まれていなければ保存しません。

if (stripos($html, '</html>') === false) {
    return $html;
}

途中で切れた出力、リダイレクト、fatal error で止まったページを保存しないためです。不完全なHTMLをキャッシュすると、その壊れたページが延々と配られます。エラーにならないので、誰も気づきません。

7. 検索結果

is_search() なら保存しません。

検索クエリは無限に作れるので、キャッシュすると際限なくファイルが増えます。しかも同じクエリが再訪される確率は低い。ヒットしないキャッシュを溜めるだけになります。

8. パスワード保護記事

post_password_required() に相当する状態なら保存しません。第1段階で wp-postpass_ cookie も見ていますが、こちらは記事側の属性として確認します。

同じ危険に対して、2箇所で判定しています。 冗長ですが、片方が抜けたときに漏れる内容が重いので、両方置いています。

9. 記事単位の無効化フラグ

投稿メタで、その記事だけキャッシュを止められるようにしています。

// 投稿メタ _prime_cache_disabled が立っていたら保存しない
if (get_post_meta($post_id, '_prime_cache_disabled', true)) {
    return $html;
}

動的な要素を含む記事を、利用者が個別に外せるようにするためです。設定の正規表現でURIを指定してもらうより、編集画面のチェックボックスのほうが間違いが少なかったので。

10. レスポンスが短すぎる

255バイト未満なら保存しません。

正常なHTMLがその長さになることはないので、何かが失敗した結果だと判断できます。空の出力や、エラーメッセージだけのレスポンスを保存しないための、最後の網です。

保存の実装:原子的に書く

判定を通ったら、ファイルに書きます。ここも一箇所だけ注意点があります。

function my_atomic_write(string $path, string $content): bool {
    $dir = dirname($path);
    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
        return false;
    }
    // 同一ディレクトリに一時ファイルを作る(rename が同一FS内になるように)
    $tmp = $path . '.' . getmypid() . '.tmp';
    if (file_put_contents($tmp, $content, LOCK_EX) === false) {
        return false;
    }
    // rename は同一FS内なら原子的
    if (!rename($tmp, $path)) {
        @unlink($tmp);
        return false;
    }
    return true;
}

直接 file_put_contents($path, ...) にすると、書き込み途中のファイルを他のリクエストが読みます。読んだ側は、途中で切れたHTMLを受け取る。しかもエラーにならないので、気づきません。

一時ファイルを同じディレクトリに作るのも理由があります。/tmp に作ってから rename すると、ファイルシステムをまたぐ場合があり、その rename は原子的になりません。

gzip版も同時に作っておくと、対応するクライアントに .html.gz をそのまま返せます。

キーの設計で、やらなかったこと

除外条件と並んで、キーの正規化にも判断があります。2つだけ書いておきます。

rawurldecode をしません。 デコードすると %2F/ が同じものになり、別々のURLが同じキーに衝突します。「正規化したほうがきれいだ」と思ってやると、異なるものを同じにしてしまう。キャッシュキーでは、これが事故になります。

ハッシュを切り詰めすぎません。 cookie やクエリの組み合わせを md5 で畳んでいますが、16桁(64bit)残しています。短くすると、攻撃者が同じハッシュになる値を安価に作れて、他人が見るバリアントに内容を流し込めます。ハッシュは短くしたくなる場所ですが、切り詰めた分だけ攻撃コストが下がります。

確認手順

実装したら、この順で確かめます。実際に自分がやっている順です。

1. ログアウト状態で、素のページ。 シークレットウィンドウで開いて、2回目が速くなるか。キャッシュファイルが実際に作られているかも、ディレクトリを見て確認します。

2. ログイン状態で、キャッシュされないこと。 ログインしたまま同じページを開いて、キャッシュファイルが増えないか。ここが漏れていると、いちばんまずい形の事故になります。

3. ログイン→ログアウトの直後。 Set-Cookie が付くタイミングで保存されていないか。ログイン直後とログアウト直後の両方を見ます。

4. ?utm_source=test を付けて開く。 キャッシュが増えないこと(同じキーに集約されること)。

5. 適当なパラメータ ?zzz=1 を付けて開く。 許可リストにないので、キャッシュされないこと。ここでキャッシュが増えるなら、許可リストが機能していません。

6. POST を投げる。 フォームを送信して、その結果がキャッシュされないこと。

7. 404 と検索。 存在しないURLと ?s=test で、意図した挙動になっているか。

8. パスワード保護記事。 パスワードを入れた状態のHTMLが保存されていないこと。

9. キャッシュを消して、もう一度全部。 クリア機能が本当に消しているか。消し残しがあると、古い内容が配られ続けます。

10. 設定の正規表現に、壊れたパターンを入れる。 [ だけを入れてみて、キャッシュが止まること(fail-close の確認)。ここを確かめる人は少ないと思いますが、ここが通ってしまう実装だと、除外設定が静かに無効になります。

実装の順番

全部をいっぺんに作る必要はありません。効く順に並べるとこうです。

1. メソッド・ログイン cookie・管理画面の除外。 この3つがないと、公開してはいけない状態です。ここだけは最初に入れます。

2. 原子的な書き込み。 あとから直すのは面倒なので、最初から rename で書きます。

3. 第2段階の Set-Cookie</html> レンダリング後の判定で、いちばん事故を防ぐ2つです。

4. クエリの許可リスト。 ディスクを埋められる問題への対処。公開する前に入れておきたい。

5. fail-close の徹底。 判定の失敗を、全部「保存しない」に倒す。実装のバグが情報漏洩にならない状態を作ります。

6. gzip、304、モバイル分離、404 キャッシュ。 ここからは速さと効率の話なので、後回しでいい。

逆に、最初から作り込まなくていいのは、キャッシュの自動削除の細かい制御でした。投稿更新時に全消しでも、最初は動きます。細かく差分だけ消す仕組みは、運用してから必要な形が見えてきます。

早見でまとめ

  • ページキャッシュの実装量は、返す処理より「返さない判定」が本体
  • 判定は2段階。WordPressロード前(リクエストで分かる分)と、レンダリング後(HTMLとヘッダで分かる分)
  • ロード前:メソッド / Authorization / Host許可リスト / システムURL / ECパス / バイパスcookie(前方一致)/ 非HTML拡張子 / クエリ許可リスト / 設定の正規表現 / モバイル / スキーム不一致
  • レンダリング後:ステータス / DONOTCACHEPAGE / Set-Cookie / Cache-Control(no-cache は許可、禁止は privateno-store)/ Vary / </html> の有無 / 検索 / パスワード保護 / 記事単位フラグ / 255バイト未満
  • 管理画面の判定は境界アンカー付きで。部分一致は普通の記事URLを巻き込む
  • 判定に失敗したら保存しない(fail-close)。正規表現のコンパイルエラーも含めて
  • 書き込みは同一ディレクトリの一時ファイル + rename。直接書き込みは途中を読まれる
  • キーは rawurldecode しない。ハッシュを切り詰めすぎない
  • 対応できない Vary は、キャッシュしないと決める

除外のリストは、書いているうちに伸び続けました。いまも、まだ抜けているものがある気がしています。キャッシュの実装で怖いのは遅くなることではなく、誰かのページを誰かに見せることなので、そこは伸ばし続けるつもりです。役に立ったらストックして、キャッシュを実装するとき手元に置いてください。

実物はここで読めます。


ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。

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?