TECHNICAL ARTICLE / SYSTEM ARCHITECTURE

静的サイトにおけるクロスオリジン問い合わせフォームの CORS・CSRF・タイミング制御

GitHub Pages(静的ホスティング)から外部 PHP バックエンド API へ AJAX 送信するお問い合わせフォームにおいて、ブラウザとサーバー間のヘッダー差異、タイミング攻撃防御、DOM 再構成時の値欠落トラブルを調査・解決したリアルな現場ログ。


1. 発生した問題 (Problem)

GitHub Pages 上で運用する静的ポートフォリオ(koshijpn.github.io)から、別ドメインで稼働する共通お問い合わせバックエンド(koshijpn.com/contact/send.php)へ AJAX 送信を行った際、以下の 3 つの問題が同時に発生しました。

2. 原因の調査と特定 (Research & Root Cause)

ブラウザの Network タブ、DevTools、および send.php のバリデーションコードを照合し、以下の根本原因を突き止めました。

① AJAX 判定ヘッダーの欠落と 303 Redirect 追従

バックエンドの send.php は、リクエストヘッダーに X-Requested-With: XMLHttpRequest が含まれているかによって「AJAX リクエスト(JSON 応答)」と「通常フォーム送信(HTTP 303 Redirect)」を分岐していました。フロントエンドの fetch()Accept: application/json のみを渡していたため、サーバーが 303 Redirect を返し、fetch() がそのリダイレクト先 HTML ページを取得して response.json() パースエラー(SyntaxError)を吐いていました。

② DOM 再生成時の privacyConsent チェックボックス値フォールバック

多言語切替スクリプト(contact-i18n.js)において node.replaceChildren(...) で DOM ノードを置き換えた際、HTML 上で指定されていた value="accepted" 属性がブラウザの標準デフォルト値 value="on" へフォールバックしていました。send.php 側の $privacyConsent === 'accepted' 判定に引っかかり HTTP 422 invalid_input が発生していました。

sourcePage URL に含まれる括弧・スペースによる filter_var 失敗

他ページからクエリ付きで流入した際、sourcePage の値に https://.../contact/?ref=portfolio (via portfolio) という説明文字列を付与していたため、サーバー側の filter_var($sourcePage, FILTER_VALIDATE_URL) が URL 不正と判定していました。

3. 実装とコード例 (Implementation)

フロントエンド(JavaScript)およびバックエンド(PHP)の両面で最小限かつ堅牢な修復を行いました。

フロントエンド側 (JavaScript: js/contact-form.js)

// 1. sourcePage の URL クリーン化
const cleanUrl = location.href.split('#')[0].split(' ')[0];
setValue('sourcePage', cleanUrl);

// 2. 超高速入力による invalid_timing 発生を防ぐ自動待機バッファ
const startedAtVal = Number(form.elements.namedItem('startedAt')?.value || Date.now());
const elapsed = Date.now() - startedAtVal;
if (elapsed < 3100) {
  await new Promise(res => setTimeout(res, 3100 - elapsed));
}

// 3. Simple CORS / AJAX 識別ヘッダーの付与
const response = await fetch(form.action, {
  method: 'POST',
  body: new FormData(form),
  headers: {
    'Accept': 'application/json',
    'X-Requested-With': 'XMLHttpRequest'
  },
  mode: 'cors',
  credentials: 'omit'
});

バックエンド側 (PHP: send.php)

// チェックボックスの標準値 ('on', 'true', '1') も受容するように緩和
$privacyConsent = (string) ($_POST['privacyConsent'] ?? '');
$privacyValid = in_array(strtolower($privacyConsent), ['accepted', 'on', 'true', '1'], true);

// サイトごとに Rate Limit ファイルキーを分離し、有効なメール送信時のみカウント加算
$key = hash('sha256', 'koshi-contact|' . $siteId . '|' . $address);
$path = sys_get_temp_dir() . '/koshi-contact-' . $key . '.json';

4. 検証結果と学び (Verification & Key Takeaways)