PHPでお問い合わせフォームを作る：CSRF対策

## はじめに

タイトルについて記事にしました。
この記事で得る内容は以下の通りです。

・ クロスサイトリクエストフォージェリ（CSRF）、トークンについて

[PHPでお問い合わせフォームを作る](https://qiita.com/s79ns/items/62ce69fef20258f35534)という記事にてお問い合わせフォームを作成しましたが
この記事では**クロスサイトリクエストフォージェリ(CSRF)**と呼ばれる攻撃の対策について紹介します。

## 概要

前回で入力・確認・完了画面があるお問い合わせフォームを作っていて、最後に入力者と管理者宛に入力した内容をメールで送信するというものを作っていました。

一通りのお問い合わせフォームとしての形は完成していますが、メール送信後のチェックが行われていないので、今回の記事ではこちらの対策を行います。

![スクリーンショット 2021-02-03 13.35.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772631/a423719c-de43-7355-5355-136f89188757.png)

## クロスサイトリクエストフォージェリ(CSRF)

悪意のある人がフィッシング用のWebページを作成し、そのページの送信ボタンを押してしまうことで
本来のお問い合わせフォームにある送信ボタンを押した時と同じ動作をさせることができます。
そうすると、意図せず勝手にメールが送信され、身に覚えがないメールを受信する可能性があります。
このような攻撃のことを、**クロスサイトリクエストフォージェリ(CSRF)**といいます。

![スクリーンショット 2021-02-03 14.09.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772631/be57e12c-4ca1-635f-d05a-6219891dac67.png)

本来であれば、入力→確認画面という順番を経て送信ボタンを押す流れですが、フィッシングサイトに
誘導されてしまうと、入力画面を通さずに送信ボタンを押すフローになってしまいます。

#### トークンについて

CSRF攻撃の対策として**合言葉**を使うと効果的です。下の図をご覧下さい。

![スクリーンショット 2021-02-03 13.26.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772631/5f7a093a-9654-34ab-dbc5-699c36017017.png)

① まず、入力画面にて『確認』ボタンを押下した際の処理で合言葉を作ります。
<font color="gray">※ 作成したトークンを$_SESSION["token"]に保存します</font>

② 確認画面を返す際に、作成した合言葉も一緒に乗せます。

③ 『送信』ボタンを押下した際に、その合言葉がPOSTでサーバに届くようにします。

最初に作成した合言葉と、POSTとして送られてきた合言葉が一致すれば、入力画面経由で来た
確認画面であると保証することができます。
従って、合言葉の一致を確認することができたら以降の処理を継続する流れが完成します。
この合言葉のことを**トークン**といいます。

#### トークンの生成方法

トークンは基本的に何でもいいですが、一般的には毎回変わる新しいものを使います。
先程のように『何もせずに黙って3分間待つ』という~~スーファミ世代がバレる~~トークンでは、それが漏れてしまうと次から使えなくなってしまいます。

PHPのバージョンによって作り方が異なりますが、PHP5の際はこちらです。

```php:PHP5
$token = bin2hex(mcrypt_create_iv(32, MCRYPT_DEV_URANDAM));
```

PHP7の際は以下の通りです。**random_bytes()関数**は暗号論的に証明されている関数です。

```php:PHP7
$token = bin2hex(random_bytes(32));
```

これらによって生成される文字列は、以下のような64byteのランダムな文字列です。

```php
gadjflhkashfahdiuahteuaifhbai;htariufjdaafeatgafataghagdayravorm
```

## コードの追加

下記のコードは、確認ボタンが押された際の分岐になっていた部分を抜粋したものです。

名前・Eメール・本文の入力チェックを経て、エラーがあれば入力画面に戻り、エラーがなければ
確認画面に戻る進む処理になっていました。

```php
  } else if( isset($_POST["confirm"] ) && $_POST["confirm"] ){
    // 確認ボタンを押下
    if( !$_POST["fullname"] ){
      $errmessage[] = "名前を入力して下さい";
    } else if( mb_strlen($_POST["fullname"]) ) > 100 ){
      $errmessage[] = "名前は100文字以内にして下さい";
    }
    $_SESSION["fullname"] = htmlspecialchars($_POST["fullname"], flags: ENT_QUOTES);

    if( !$_POST["email"] ){
      $errmessage[] = "Eメールを入力して下さい";
    } else if( mb_strlen($_POST["email"]) ) > 200 ){
      $errmessage[] = "Eメールは200文字以内にして下さい";
    } else if( !filter_var($_POST["email"], filter: FILTER_VALIDETE_EMAIL ){
      $errmessage[] = "メールアドレスが不正です";
    }
    $_SESSION["email"] = htmlspecialchars($_POST["email"], flags: ENT_QUOTES);

    if( !$_POST["message"] ){
      $errmessage[] = "本文を入力して下さい";
    } else if( mb_strlen($_POST["message"]) ) > 500 ){
      $errmessage[] = "本文は500文字以内にして下さい";
    }
    $_SESSION["message"] = htmlspecialchars($_POST["message"], flags: ENT_QUOTES);

    if( $errmessage ){
      $mode = "input";
    else {
      $mode = "confirm";
    }
```

#### トークンの作成

確認画面に進む際にトークンを作れば良いので、お使いのPHPのバージョンに合わせてコードを追加します。
そして、生成したトークンを$_SESSION["token"]に保存します。

```php
    if( $errmessage ){
      $mode = "input";
    else {
      $token = bin2hex(random_bytes(32));
      $_SESSION["token"] = $token;
      $mode = "confirm";
    }
```

これで確認ボタンを押下した際の追加処理ができました。

#### 作成したトークンを送信する処理の追加

作成したトークンが確認画面に渡り、送信ボタンを押下すると同時にトークンも一緒に送るように
送信させるためのinputタグを追加します。

下記のコードは、確認画面を出すためのフォームです。

```php
  <?php else if( $mode == "comfirm" ){ ?>
    <!-- 確認画面 -->
    <form action="./cotactform.php" method="post">
      名前 <?php echo $_SESSION["fullname"] ?>
      Eメール <?php echo $_SESSION["Email"] ?>
      本文 <?php echo nl2br($_SESSION["message"]) ?>
      <input type="submit" name="back" value="戻る" />
      <input type="submit" name="send" value="送信" />
    </form>
```

formタグの下に下記のように1行inputタグを追加して下さい。
inputタグを隠した状態にしたいので、typeをhiddenに指定しています。そしてvalueに先程の$_SESSION["token"]を埋め込みます。

```php
  <?php else if( $mode == "comfirm" ){ ?>
    <!-- 確認画面 -->
    <form action="./cotactform.php" method="post">
      <input type="hidden" name="token" value="<?php echo $_SESSION["token"]; ?>">
      名前 <?php echo $_SESSION["fullname"] ?>
      Eメール <?php echo $_SESSION["Email"] ?>
      本文 <?php echo nl2br($_SESSION["message"]) ?>
      <input type="submit" name="back" value="戻る" />
      <input type="submit" name="send" value="送信" />
    </form>
```

これで送信ボタンを押下した際に、一緒にトークンも送るようになりました。

#### 送信ボタン押下時の処理の追加

下記のコードは送信ボタンを押下した際の処理です。
送られてきたトークンが一致するかの処理をこちらに追加します。

```php
  } else if( isset($_POST["send"] ) && $_POST["send"] ){
    // 送信ボタンを押下
    $message = "お問い合わせを受け付けました\r\n"
             . "名前" . $_SESSION["fullname"] . "\r\n"
             . "email" . $_SEESION["email"] . "\r\n"
             . "お問い合わせ内容:\r\n"
             . preg_replace("/\r\n|\r|\n", "\r\n", $_SESSION["message"]);
    mail($_SESSION["email"], "お問い合わせありがとうございます", $message);
    mail("hoge@fuga.com", "お問い合わせありがとうございます", $message);
    $mode = "send";
  } else {
    $SESSION = array();
?>
```

##### トークンが一致しない場合の処理の追加

もしPOSTのトークンとセッションのトークンが一致していなければ、エラーメッセージを表示します。
念のためセッションを初期化しておき、そのまま完了画面に進まれていけないので、入力画面に戻すためにモードをinputにしておきます。

それ以外の時は、正しくメールを送る処理をさせたいのでelseで元々書いていたコードを囲みます。

```php
  } else if( isset($_POST["send"] ) && $_POST["send"] ){
    // 送信ボタンを押下
    if( $_POST["token"] != $_SESSION["token"] ){
      $errmessage[] = "不正な処理が行われました";
      $_SESSION = array();
      $mode = "input";
    } else {
      $message = "お問い合わせを受け付けました\r\n"
               . "名前" . $_SESSION["fullname"] . "\r\n"
               . "email" . $_SEESION["email"] . "\r\n"
               . "お問い合わせ内容:\r\n"
               . preg_replace("/\r\n|\r|\n", "\r\n", $_SESSION["message"]);
      mail($_SESSION["email"], "お問い合わせありがとうございます", $message);
      mail("hoge@fuga.com", "お問い合わせありがとうございます", $message);
      $mode = "send";
    }
  } else {
    $SESSION = array();
?>
```

##### トークンが存在しない・メールアドレスがない場合の処理の追加

そもそもトークンが存在しない、または何らかの理由でセッションのトークン情報が作られていなかった時、更にメールアドレスが記入されていない場合は処理を継続することができませんので、先程と同様エラーメッセージを返します。
そして、先程追加した処理のelseをelse ifに修正します。

```php
  } else if( isset($_POST["send"] ) && $_POST["send"] ){
    // 送信ボタンを押下
    if( !$_POST["token"] || !$_SESSION["token"] || !$_SESSION["email"] ){
      $errmessage[] = "不正な処理が行われました";
      $_SESSION = array();
      $mode = "input";
    } else if( $_POST["token"] != $_SESSION["token"] ){
      $errmessage[] = "不正な処理が行われました";
      $_SESSION = array();
      $mode = "input";
    } else {
      $message = "お問い合わせを受け付けました\r\n"
               . "名前" . $_SESSION["fullname"] . "\r\n"
               . "email" . $_SEESION["email"] . "\r\n"
               . "お問い合わせ内容:\r\n"
               . preg_replace("/\r\n|\r|\n", "\r\n", $_SESSION["message"]);
      mail($_SESSION["email"], "お問い合わせありがとうございます", $message);
      mail("hoge@fuga.com", "お問い合わせありがとうございます", $message);
      $mode = "send";
    }
  } else {
    $SESSION = array();
?>
```

最初のifで入力されるべき値があるかを判定し、次にその条件をクリアしてもトークンが一致していなければ不正な値として扱い、それ以外の時（else）は正常な場合の処理として扱います。

## 完成したコード

完成したコードがこちらです。

```php:contactform.php
<?php
session_start();
$mode = 'input';
$errmessage = array();
if( isset($_POST['back']) && $_POST['back'] ){
	// 何もしない
} else if( isset($_POST['confirm']) && $_POST['confirm'] ){
	// 確認画面
	if( !$_POST['fullname'] ) {
		$errmessage[] = "名前を入力してください";
	} else if( mb_strlen($_POST['fullname']) > 100 ){
		$errmessage[] = "名前は100文字以内にしてください";
	}
	$_SESSION['fullname']	= htmlspecialchars($_POST['fullname'], ENT_QUOTES);

	if( !$_POST['email'] ) {
		$errmessage[] = "Eメールを入力してください";
	} else if( mb_strlen($_POST['email']) > 200 ){
		$errmessage[] = "Eメールは200文字以内にしてください";
	} else if( !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL) ){
		$errmessage[] = "メールアドレスが不正です";
	}
	$_SESSION['email']	= htmlspecialchars($_POST['email'], ENT_QUOTES);

	if( !$_POST['message'] ){
		$errmessage[] = "お問い合わせ内容を入力してください";
	} else if( mb_strlen($_POST['message']) > 500 ){
		$errmessage[] = "お問い合わせ内容は500文字以内にしてください";
	}
	$_SESSION['message'] = htmlspecialchars($_POST['message'], ENT_QUOTES);

	if( $errmessage ){
		$mode = 'input';
	} else {
	  $token = bin2hex(mcrypt_create_iv(32, MCRYPT_DEV_URANDOM)); // php5のとき
	  //$token = bin2hex(random_bytes(32));                                   // php7以降
	  $_SESSION['token']  = $token;
		$mode = 'confirm';
	}
} else if( isset($_POST['send']) && $_POST['send'] ){
	// 送信ボタンを押したとき
  if( !$_POST['token'] || !$_SESSION['token'] || !$_SESSION['email'] ){
	  $errmessage[] = '不正な処理が行われました';
	  $_SESSION     = array();
	  $mode         = 'input';
  } else if( $_POST['token'] != $_SESSION['token'] ){
    $errmessage[] = '不正な処理が行われました';
    $_SESSION     = array();
    $mode         = 'input';
  } else {
	  $message = "お問い合わせを受け付けました \r\n"
	             . "名前: " . $_SESSION['fullname'] . "\r\n"
	             . "email: " . $_SESSION['email'] . "\r\n"
	             . "お問い合わせ内容:\r\n"
	             . preg_replace( "/\r\n|\r|\n/", "\r\n", $_SESSION['message'] );
	  mail( $_SESSION['email'], 'お問い合わせありがとうございます', $message );
	  mail( 'hoge@hoge.com', 'お問い合わせありがとうございます', $message );
	  $_SESSION = array();
	  $mode     = 'send';
  }
} else {
	$_SESSION['fullname'] = "";
	$_SESSION['email']    = "";
	$_SESSION['message']  = "";
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>お問い合わせフォーム</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  <style>
    body{
      padding: 10px;
      max-width: 600px;
      margin: 0px auto;
    }
    div.button{
      text-align: center;
    }
  </style>
</head>
<body>
<?php if( $mode == 'input' ){ ?>
  <!-- 入力画面 -->
	<?php
	if( $errmessage ){
		echo '<div class="alert alert-danger" role="alert">';
		echo implode('<br>', $errmessage );
		echo '</div>';
	}
	?>
  <form action="./contactform.php" method="post">
    名前    <input type="text"    name="fullname" value="<?php echo $_SESSION['fullname'] ?>"  class="form-control"><br>
    Eメール <input type="email"   name="email"    value="<?php echo $_SESSION['email'] ?>"  class="form-control"><br>
    お問い合わせ内容<br>
    <textarea cols="40" rows="8" name="message"  class="form-control"><?php echo $_SESSION['message'] ?></textarea><br>
    <div class="button">
      <input type="submit" name="confirm" value="確認" class="btn btn-primary btn-lg"/>
    </div>
  </form>
<?php } else if( $mode == 'confirm' ){ ?>
  <!-- 確認画面 -->
  <form action="./contactform.php" method="post">
    <input type="hidden" name="token" value="<?php echo $_SESSION['token']; ?>">
    名前    <?php echo $_SESSION['fullname'] ?><br>
    Eメール <?php echo $_SESSION['email'] ?><br>
    お問い合わせ内容<br>
	  <?php echo nl2br($_SESSION['message']) ?><br>
    <input type="submit" name="back" value="戻る" class="btn btn-primary btn-lg"/>
    <input type="submit" name="send" value="送信" class="btn btn-primary btn-lg"/>
  </form>
<?php } else { ?>
  <!-- 完了画面 -->
  送信しました。お問い合わせありがとうございました。<br>
<?php } ?>
</body>
</html>
```