ReactフォームにCSRF対策を追加する方法

Reactを使用したお問い合わせフォームにCSRF(クロスサイトリクエストフォージェリ)対策を追加する方法を解説します。本記事では、以下のステップで安全なフォーム送信の実装を行います。


1. 実装の背景

CSRF攻撃とは、ユーザーの意図しないリクエストを第三者が実行させる攻撃です。これを防ぐために、サーバー側でCSRFトークンを生成し、フォーム送信時にそのトークンを検証する仕組みを導入します。


2. 技術情報とバージョン情報

本記事で使用した技術とバージョンは以下の通りです:

  • フロントエンド
  • React: 17.x
  • Tailwind CSS: 3.x
  • React Router DOM: 5.3.4
  • reCAPTCHA: v3
  • バックエンド
  • PHP: 7.4.x
  • サーバー環境: Apache (エックスサーバー)
  • その他
  • セッションを使用したCSRFトークン管理。
  • JSON形式でデータのやり取りを実施。

3. プロジェクトのファイル構成

以下は今回のプロジェクト構成です:

project/
├── index.html                // フロントエンドのエントリーポイント
└── backend/
    ├── csrf_token.php        // CSRFトークン生成
    └── contact.php           // フォーム送信処理

4. フロントエンドの実装

Reactを使用してフォームの作成および送信処理を構築します。以下のコードをindex.htmlに記述してください。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React お問い合わせフォーム</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/react@17/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/react-router-dom@5.3.4/umd/react-router-dom.min.js"></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  <script src="https://www.google.com/recaptcha/api.js?render=your-site-key"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
  <div id="root" class="container mx-auto p-4 max-w-screen-md"></div>
  <script type="text/babel">
    const { BrowserRouter, Route, Switch, useHistory } = window.ReactRouterDOM;
    const { useState, useEffect } = React;

    function InputForm() {
      const history = useHistory();
      const [formData, setFormData] = useState({
        name: "",
        email: "",
        message: "",
      });

      useEffect(() => {
        // 初回ロード時にCSRFトークンを取得
        async function fetchCSRFToken() {
          try {
            const response = await fetch("backend/csrf_token.php");
            const data = await response.json();
            if (data.csrf_token) {
              localStorage.setItem("csrf_token", data.csrf_token);
            }
          } catch (error) {
            alert("CSRFトークンの取得に失敗しました。");
          }
        }
        fetchCSRFToken();
      }, []);

      const handleChange = (e) => {
        const { name, value } = e.target;
        setFormData((prev) => ({ ...prev, [name]: value }));
      };

      const handleSubmit = (e) => {
        e.preventDefault();
        localStorage.setItem("formData", JSON.stringify(formData));
        history.push("/confirm");
      };

      return (
        <div className="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
          <h1 className="text-2xl font-bold mb-4">お問い合わせフォーム</h1>
          <form onSubmit={handleSubmit}>
            <div className="mb-4">
              <label htmlFor="name" className="block text-sm font-medium text-gray-700">名前:</label>
              <input
                type="text"
                id="name"
                name="name"
                value={formData.name}
                onChange={handleChange}
                required
                className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
              />
            </div>
            <div className="mb-4">
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">メールアドレス:</label>
              <input
                type="email"
                id="email"
                name="email"
                value={formData.email}
                onChange={handleChange}
                required
                className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
              />
            </div>
            <div className="mb-4">
              <label htmlFor="message" className="block text-sm font-medium text-gray-700">お問い合わせ内容:</label>
              <textarea
                id="message"
                name="message"
                value={formData.message}
                onChange={handleChange}
                required
                className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
              />
            </div>
            <button type="submit" className="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
              確認画面へ
            </button>
          </form>
        </div>
      );
    }

    function ConfirmForm() {
      const history = useHistory();
      const formData = JSON.parse(localStorage.getItem("formData")) || {};
      const csrfToken = localStorage.getItem("csrf_token");

      const handleBack = () => {
        history.push("/");
      };

      const handleSubmit = async () => {
        grecaptcha.ready(async () => {
          const token = await grecaptcha.execute("your-site-key", { action: "submit" });
          fetch("backend/contact.php", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ ...formData, recaptcha_token: token, csrf_token: csrfToken }),
          })
            .then((res) => res.json())
            .then((data) => {
              if (data.success) {
                localStorage.removeItem("formData");
                history.push("/complete");
              } else {
                alert("送信に失敗しました: " + data.error);
              }
            })
            .catch(() => alert("エラーが発生しました。通信環境を確認してください。"));
        });
      };

      return (
        <div className="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
          <h1 className="text-2xl font-bold mb-4">内容の確認</h1>
          <p className="mb-2"><strong>名前:</strong> {formData.name}</p>
          <p className="mb-2"><strong>メールアドレス:</strong> {formData.email}</p>
          <p className="mb-4"><strong>お問い合わせ内容:</strong> {formData.message}</p>
          <div className="flex space-x-4">
            <button onClick={handleBack} className="w-1/2 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">
              戻る
            </button>
            <button onClick={handleSubmit} className="w-1/2 bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
              送信する
            </button>
          </div>
        </div>
      );
    }

    function CompleteForm() {
      const history = useHistory();
      return (
        <div className="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
          <h1 className="text-2xl font-bold mb-4">送信が完了しました</h1>
          <p>お問い合わせいただきありがとうございます。</p>
          <button
            onClick={() => history.push("/")}
            className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
          >
            フォームに戻る
          </button>
        </div>
      );
    }

    function App() {
      return (
        <BrowserRouter>
          <Switch>
            <Route exact path="/" component={InputForm} />
            <Route path="/confirm" component={ConfirmForm} />
            <Route path="/complete" component={CompleteForm} />
          </Switch>
        </BrowserRouter>
      );
    }

    ReactDOM.render(<App />, document.getElementById("root"));
  </script>
</body>
</html>

5. バックエンドの実装

CSRFトークン生成: csrf_token.php

<?php
// csrf_token.php
session_start();

// トークン生成(セッションに保存)
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// レスポンスをJSONで返す
header('Content-Type: application/json');
echo json_encode(['csrf_token' => $_SESSION['csrf_token']]);

フォーム送信処理: contact.php

<?php
session_start();
header('Content-Type: application/json');

// リクエストデータの取得
$data = json_decode(file_get_contents('php://input'), true);

// CSRFトークンの検証
if (empty($_SESSION['csrf_token']) || empty($data['csrf_token']) || $_SESSION['csrf_token'] !== $data['csrf_token']) {
    echo json_encode(['success' => false, 'error' => 'CSRFトークンが無効です。']);
    exit;
}

// reCAPTCHA の検証
$recaptcha_url = 'https://www.google.com/recaptcha/api/siteverify';
$recaptcha_secret = 'XXXXXXXXXXXXXXXXXXXXXXXXX'; // シークレットキーを設定
$recaptcha_response = $data['recaptcha_token'];

$response = file_get_contents($recaptcha_url . '?secret=' . $recaptcha_secret . '&response=' . $recaptcha_response);
$recaptcha = json_decode($response, true);

if (!$recaptcha['success']) {
    echo json_encode(['success' => false, 'error' => 'reCAPTCHA 検証に失敗しました。']);
    exit;
}

// 入力データのサニタイズとバリデーション
$name = htmlspecialchars($data['name'], ENT_QUOTES, 'UTF-8');
$email = filter_var($data['email'], FILTER_VALIDATE_EMAIL);
$message = htmlspecialchars($data['message'], ENT_QUOTES, 'UTF-8');

if (!$email) {
    echo json_encode(['success' => false, 'error' => 'メールアドレスが無効です。']);
    exit;
}

// メール送信処理
$toAdmin = 'admin@example.com';
$subjectAdmin = 'お問い合わせフォームからのメッセージ';
$messageAdmin = "名前: $name\nメールアドレス: $email\nメッセージ: $message";
$headersAdmin = 'From: example@example.com';

$mailToAdmin = mail($toAdmin, $subjectAdmin, $messageAdmin, $headersAdmin);

// 問い合わせ者への自動返信メール
$toUser = $email;
$subjectUser = 'お問い合わせありがとうございます';
$messageUser = "$name 様\n\nお問い合わせありがとうございます。\n以下の内容で受け付けました。\n\n----------------------\nお名前: $name\nメールアドレス: $email\nお問い合わせ内容:\n$message\n----------------------\n\n担当者より折り返しご連絡いたします。\n\nよろしくお願いいたします。\n";
$headersUser = 'From: example@example.com';

$mailToUser = mail($toUser, $subjectUser, $messageUser, $headersUser);

// レスポンス
if ($mailToAdmin && $mailToUser) {
    echo json_encode(['success' => true]);
} else {
    echo json_encode(['success' => false, 'error' => 'メール送信に失敗しました。']);
}

6. まとめ

これで、ReactフォームにCSRF対策を追加する実装が完了しました。実装のポイントは以下の通りです:

  1. セッションを用いてCSRFトークンを管理し、フォーム送信時に検証する。
  2. Reactでトークンを取得・保持し、安全に送信する仕組みを構築。
  3. reCAPTCHAを組み合わせてさらなるセキュリティ強化。

コメント

タイトルとURLをコピーしました