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 つの問題が同時に発生しました。
- CLI テストは成功するのに、実ブラウザから送信すると「送信できませんでした」と拒否される。
- ページ遷移(例:
?ref=portfolio)経由でアクセスした際、HTTP 422invalid_inputエラーとなる。 - フォームを高速に入力・送信した際、スパム判定(HTTP 400
invalid_timing)によってブロックされる。
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)
- CLI テストと実ブラウザ送信の違い: CLI (curl) で純粋なパラメータを送るだけでは、ブラウザの DOM 動的変更(
replaceChildrenによるvalue変化)や多言語スクリプトのサイドエフェクトを検出できません。必ず実ブラウザのFormData実測が必要でした。 - エラーマッピングの重要性: バックエンドが返す
rate_limited,invalid_timing,invalid_inputなどのコードを UI 上ですべて一律の「送信失敗」に潰さず、具体的な解決方法(「10分待機」「入力形式確認」)を言語別に提示することで UX が向上しました。