前提: 一部だけを元のホストに残す構成

サイト本体はCloudflareへ移しましたが、お問い合わせフォームのバックエンド(CSRF検証・reCAPTCHA・レート制限・メール送信を持つPHP)だけは、書き換えずに元のホスティングでそのまま動かし続ける構成にしました。

サイト側からは、フォーム送信のリクエストだけをこのPHPへ中継します。それ以外の経路には影響がないはずでした。

気付いたきっかけ

しばらく経ってから、到達面を洗い出す監査の一環で、元のホスト側のSSL証明書の状態を確認しました。

自動更新が失敗していました。 証明書は既存のものがまだ有効で、フォームは問題なく動いていました。ただし次の更新のタイミングで、証明書は失効することが分かりました。

ドメインを切り替えた時点では、何も壊れていません。壊れるのは90日後でした。

真因: 自動更新の条件は「Aレコードの向き先」だった

元のホスティング事業者の無料SSL(Let’s Encrypt)は、証明書の有効期限が切れる30日・25日・20日前に自動更新を試みます。この自動更新には条件があり、対象ホスト名のネームサーバーまたはAレコードが、そのホスティング事業者を指していることが必要でした。

ドメインをCloudflare経由にすると、AレコードはCloudflareのIPを指すようになります。この時点で自動更新の条件を満たさなくなります。

証明書の検証(HTTP-01)は、公開DNSに従って http://<ホスト名>/.well-known/acme-challenge/<token> へ届きます。ドメインがCloudflareを指していると、このリクエストはCloudflare側のアプリケーションに吸われ、元のホストには届きません。自動更新は静かに失敗し続け、証明書は最終的に失効します。

失効すると、サイト側からこのホストへのフォーム中継がTLS接続の段階で失敗します。お問い合わせフォームが恒久的に壊れることになります。

この失敗モードは切り分けやすい

証明書の検証リクエストが実際にどこへ届いているかは、直接確認できます。

curl http://<ホスト名>/.well-known/acme-challenge/probe
  • 元のホストのネイティブな404/403ページが返れば、リクエストは直接届いています(自動更新は正常に機能します)
  • アプリケーション側のカスタム404が返れば、リクエストはCloudflare側で処理されています(自動更新は失敗し続けます)

この2つは見た目が大きく違います。今回のホスティング事業者はエラーページを独自の文字コード(EUC-JP)で返すため、UTF-8で組まれた自作のエラーページと混同しようがありません。

apex(Cloudflare経由): <!DOCTYPE html>... <meta charset="UTF-8">... (アプリ側の404)
form(DNSのみ)        : <!DOCTYPE html>... <meta charset="EUC-JP">... (ホスト側のネイティブ404)

curl一発で、更新が生きているか死んでいるかが分かります。

是正: 専用サブドメインを作り、そこだけCloudflareを経由させない

対応は、フォームのバックエンド専用にサブドメインを切り出し、そのサブドメインだけCloudflareの「DNSのみ」(プロキシなし)に設定することでした。

サブドメインのAレコードが元のホストのIPを直接指すようになるので、ホスティング事業者から見れば普通のドメインと同じです。証明書は何もしなくても自動更新され続けます。

サイト本体(apex・www)はCloudflareのCDN・WAFを経由したまま変わりません。フォームの中継先だけを、apexからこの専用サブドメインへ差し替えます。

const ORIGIN_HOST = "form.example.com"; // 専用サブドメイン(DNSのみ)

async function proxyToOriginContactPhp(request) {
  const originUrl = new URL(url.pathname + url.search, `https://${ORIGIN_HOST}`);
  // ...
}

この構成に変えると、副産物として実装も単純になります。以前はCloudflareのゾーン内から自分自身のプロキシ済みレコードへfetchすると、経路がループする問題への対策として、宛先IPを明示的に上書きする小細工が必要でした。中継先が「DNSのみ」の別ホスト名になったことで、ホスト名がそのまま元のホストへ解決するようになり、この上書きが不要になりました。

順序を間違えるとフォームが壊れる

作業には決まった順序があります。逆から進めると、その場でフォームが壊れます。

  1. サブドメインのAレコードを先に「DNSのみ」で追加する(先にDNSが向いていないと、次のホスティング事業者側の証明書発行がHTTP-01検証に失敗する)
  2. ホスティング事業者側でサブドメインを追加し、無料SSLを発行する
  3. 発行された証明書が、そのサブドメイン名義になっていることを確認する(間に合っていなければホスティング事業者側の既定の証明書のままになる)
  4. バックエンドのPHPを新しいドキュメントルートへ配置する
  5. 親ディレクトリの設定ファイルがサブドメインへ継承されていないか確認する。継承されていると意図しないルールが引き継がれる
  6. 最後に中継先を新しいサブドメインへ切り替える

この最後のステップより前に切り替えを行うと、まだ存在しないバックエンドへ中継することになり、フォームが即座に壊れます。

副次的に確認できたこと

この中継はサイト側のアプリケーションを経由するため、フォームへのリクエストは「利用者のブラウザ → Cloudflareのエッジ → サイト側アプリ → 専用サブドメイン」という経路をたどります。専用サブドメイン自体はCloudflareを経由しませんが、そこへ向かう中継処理自体はCloudflareの基盤上で動いています。

以前、信頼していたヘッダーの許可リストが実は存在しなかったで書いたクライアントIPの検証ロジックは、この構成でも壊れないことを確認しました。中継処理は受け取ったリクエストヘッダーをそのまま転送するため、エッジが付与した実クライアントIPのヘッダーは専用サブドメインまで届きます。バックエンド側は接続元をCloudflareの公式レンジと照合してからこのヘッダーを採用するので、中継元がエッジ直結か中継経由かに関わらず同じロジックで正しく動作します。

まとめ

  • ドメインの一部だけをCDN経由にし、残りを元のホストに残す構成では、元のホスト側の証明書自動更新が壊れる場合があります
  • 壊れるのは切り替えた瞬間ではなく、**次の更新のタイミング(多くは90日サイクル)**です。切り替え直後の動作確認では見つかりません
  • 自動更新の条件(ネームサーバーまたはAレコードの向き先)を確認してください。CDNのプロキシを挟むと、この条件を満たさなくなることがあります
  • 診断はcurl一発でできます。ACMEの検証パスへアクセスして、返ってくるのが元のホストのネイティブなエラーページか、アプリケーション側のカスタムページかを見てください
  • 是正は、証明書が必要なバックエンドだけを専用サブドメインへ切り出し、そのサブドメインだけCDNを経由させないことです
  • 作業には順序があります。DNSを先に向けてから証明書を発行し、バックエンドを配置し、最後に中継先を切り替えてください