1. ホーム
  2. Others
  3. 【PHP】2重ポスト、DoS 攻撃対策

【PHP】2重ポスト、DoS 攻撃対策

サイトの投稿欄、登録欄などに、form を利用した際、ユーザが入力内容を送信完了した後も、ブラウザにその内容が残ってしまい、もう一度送信をクリックして、2重ポストになってしまうことがあります。
更にこれが悪用されて、DoS 攻撃されてしまう、なんて恐れもあるわけで、対策必要ですね。

前回、PHP Mailer を利用したフォームの作成についてまとめたので、これを引き続き使って、メール送信フォームの場合を考えてみたいと思います。

やりたいこと

3段階でメールを送信するように作ります。

「form.php」 入力画面。「確認」ボタンで「check.php」へ。

↓ ↑

「check.php」 確認画面。「送信」ボタンで「submit.php」へ。「戻る」ボタンで「form.php」へ。

「submit.php」 完了画面。送信の成功、失敗を伝える。

このような流れなのですが、満たしたい条件は以下の通りです。

  1. 「form.php」で入力した内容は、送信完了まで維持。まだ送信完了していない状態なら「check.php」から「form.php」に戻っても、入力画面に初めに入力した内容が表示される。
  2. → 解決方法 セッションを利用

  3. 一度送信したら、入力内容はブラウザから消去させる。2重送信させない。
  4. → 解決方法 ワンタイムチケットを生成

1.は、PHP のセッション機能を利用すればいいわけですが、2.のワンタイムチケットの考え方について、次にまとめます。

ワンタイムチケット

1番目の「form.php」で、ランダムな文字列の $ticket を生成。
それを$_SESSION[‘ticket’] = $ticket でセッション関数に格納。
フォームの中で、<input type=”hidden”> で、$ticket を POST メソッドで送信。

という流れになるのですが、言葉より図の方が分かりやすい気がするので、図を作ってみました。

これを見ていただければ思うのですが、「submit.php」で送信終了後、ブラウザの「戻る」で「check.php」を表示した場合など、
$_POST[‘ticket’] 、 $_SESSION[‘ticket’] が存在しない、またはこの二つの値が一致しない場合、通常のこちらが期待する流れでないことが分かります。そのような状況で、初期化するなどするような仕組みに作ればよいことになります。

2重ポストできないフォーム作成

ページ例 → https://exp.hazu.jp/mail-form/form.php
(submit.php がないので、「送信」をクリックしてもメールは送れませんが、check.php までは見れます。)

form.php (入力画面)

<?php
// セッション開始
session_start();

// キャッシュしない
header( 'Cache-Control: no-store, no-cache, must-revalidate' );
header( 'Cache-Control: post-check=0, pre-check=0', false );
header( 'Pragma: no-cache' );
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");

//文字コードをUTF-8にする
mb_internal_encoding('UTF-8');
header("Content-Type: text/html; charset=UTF-8");

// ワンタイムチケットを生成
$ticket = md5(uniqid(rand(), TRUE));

// ワンタイムチケットをセッション変数に格納
$_SESSION['ticket'] = $ticket;

// HTMLでのエスケープ処理をする関数
function h($string) {
  return htmlspecialchars($string, ENT_QUOTES);
}
?>

<html lang="ja">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>メール送信フォーム</title>
  </head>
  <body>
    <h1>メール送信フォーム</h1>
    <form action="check.php" method="post">
      <div>氏名: <input type="text" name="name" id="name" value="<?php echo h(@$_SESSION['name']);?>"></div>
      <div>メールアドレス: <input type="text" name="to" id="to" value="<?php echo h(@$_SESSION['to']);?>"></div>
      <div>件名: <input type="text" name="subject" id="subject" value="<?php echo h(@$_SESSION['subject']);?>"></div>
      <div>本文: <br />
        <textarea name="message" id="message" cols="50" rows="10"><?php echo h(@$_SESSION['message']);?></textarea>
      </div>
      <input type="hidden" name="ticket" value="<?php echo h($ticket);?>" />
      <div><input type="button" onclick="submit();" value="入力内容を確認する"></div>
    </form>
  </body>
</html>

ちょっと説明足します。

16行目: ワンタイムチケットを生成しています。
$ticket = md5(uniqid(rand(), TRUE));
rand 関数は、ランダムな数桁の整数を生成します。uniqid 関数は、ランダムな13文字の文字列を生成します。この二つが合体して、同じチケットが生成される可能性はほぼゼロとなります。
さらに、md5 関数で、MD5ハッシュ値を計算して、変数に格納します。

19行目: ワンタイムチケットをセッション変数に格納しています。
このセッション関数は、submit.php でセッションが破棄されるまで、維持されることになります。

35~39行目: フォームの入力欄には、初期の状況では空、check.php から「戻る」ボタンで戻って表示された場合には、最初に入力した内容が表示されるようにします。input 要素ではvalue 属性に、textarea 要素では<textarea> と </textarea> の間に、セッション関数を表示させるようにしますが、初期値が空になるように、関数の前に @ を付けて Null を返せるようにしておきます。

41行目: 画面には表示されない状態で、$ticket を、次の check.php へ POST メソッドで受け渡します。

check.php (確認画面)

<?php
// セッションの開始
session_start();

// キャッシュしない
header( 'Cache-Control: no-store, no-cache, must-revalidate' );
header( 'Cache-Control: post-check=0, pre-check=0', false );
header( 'Pragma: no-cache' );
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");

//文字コードをUTF-8にする
mb_internal_encoding('UTF-8');
header("Content-Type: text/html; charset=UTF-8");

// チケットを確認
if (isset($_POST['ticket'])  &&  isset($_SESSION['ticket'])) {
  $ticket = $_POST['ticket'];
  if ($ticket != $_SESSION['ticket']) {
   header('Location: form.php');
   exit();
  }
} else {
  header('Location: form.php');
  exit();
}

// HTMLでのエスケープ処理をする関数
function h($string) {
  return htmlspecialchars($string, ENT_QUOTES);
}

// 入力内容の取得・変数に格納
$name    = $_POST['name'];
$to      = $_POST['to'];
$subject = $_POST['subject'];
$message = $_POST['message'];

// 入力値をセッション変数に格納
$_SESSION["name"]    = $name;
$_SESSION["to"]      = $to;
$_SESSION["subject"] = $subject;
$_SESSION["message"] = $message;

?>

<html lang="ja">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>メール送信フォーム</title>
  </head>
  <body>
    <h1>入力内容確認</h1>
    <form action="submit.php" method="post">
      <div>氏名: <?php echo h($name); ?></div>
      <div>メールアドレス: <?php echo h($to); ?></div>
      <div>件名: <?php echo h($subject); ?></div>
      <div>本文: <br />
        <?php echo h($message); ?></textarea>
      </div>
       <input type="hidden" name="ticket" value="<?php echo h($ticket);?>" />
      <div>
        <input type="button" onClick="history.back()" value="戻る" /> 
        <input type="button" onclick="submit();" value="送信" />
      </div>
    </form>
  </body>
</html>

16~25行目: ここで、POST メソッドで送られてきた ticket と、セッション変数に格納されている ticket を確認します。
$_POST[‘ticket’]、$_SESSION[‘ticket’] 、どちらか一方でもない場合 → 強制的に form.php に転送
$_POST[‘ticket’] と $_SESSION[‘ticket’] の値が一致しない場合 → 強制的に form.php に転送

22~42行目: 入力値は $_POST で取得し、それを更に、$_SESSION でセッション変数に格納し、submit.php にも、form.php に戻った場合にも持ち運べるようにします。

60行目: form.php と同じですが、画面には表示されない状態で、$ticket を、次の form.php へ POST メソッドで受け渡します。

submit.php (完了画面)

メール送信には PHP Mailer を使う前提で書いています。→ PHP Mailer を利用したフォームの作成

<?php
// セッションの開始
session_start();

// キャッシュしない
header( 'Cache-Control: no-store, no-cache, must-revalidate' );
header( 'Cache-Control: post-check=0, pre-check=0', false );
header( 'Pragma: no-cache' );
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");

//文字コードをUTF-8にする
mb_internal_encoding('UTF-8');
header("Content-Type: text/html; charset=UTF-8");

// メール日本語対応
mb_language("japanese");

// チケットを確認
if (isset($_POST['ticket'])  &&  isset($_SESSION['ticket'])) {
  $ticket = $_POST['ticket'];
  if ($ticket !== $_SESSION['ticket']) {
    echo '<html>有効期限切れ <a href="form.php" title="ホーム">入力画面へ戻る</a></html>';
    exit();
  }
} else {
    echo '<html>有効期限切れ <a href="form.php" title="ホーム">入力画面へ戻る</a></html>';
    exit();
}

// HTMLでのエスケープ処理をする関数
function h($string) {
  return htmlspecialchars($string, ENT_QUOTES);
}

// 変数にセッション変数を代入
$name    = $_SESSION['name'];
$to      = $_SESSION['to'];
$subject = $_SESSION['subject'];
$message = $_SESSION['message'];

// PHPMailer クラスをネーム空間にインポート
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;

// Composer の autoloader をロード
require 'vendor/autoload.php';

// インスタンス生成
$mail = new PHPMailer(true);

try {
    //Server settings
    $mail->isSMTP();                             // SMTP 利用
    $mail->Host       = 'smtp.gmail.com';        // SMTP サーバー(Gmail の場合これ)
    $mail->SMTPAuth   = true;                    // SMTP認証を有効にする
    $mail->Username   = 'nana.rome@gmail.com';   // ユーザ名 (Gmail ならメールアドレス)
    $mail->Password   = 'gvrvkkcewyspzffl';      // パスワード
    $mail->SMTPSecure = 'tls';                   // 暗号化通信 (Gmail では使えます)
    $mail->Port       = 587;                     // TCP ポート (TLS の場合 587)

    // メール本体
    $mail->setFrom('nana.rome@gmail.com', 'hazuki');  // 送信元メールアドレスと名前
    $mail->addAddress($to, mb_encode_mimeheader($name, 'ISO-2022-JP')); // 送信先メールアドレスと名前
    $mail->Subject = mb_encode_mimeheader($subject, 'ISO-2022-JP');  // 件名
    $mail->Body    = mb_convert_encoding($message, "UTF-8","AUTO");  // 本文

    // 送信
    $mail->send();
    echo '送信済み';

    // セッション変数を破棄
    $_SESSION = array();
    session_destroy();

} catch (Exception $e) {
    echo "送信失敗: {$mail->ErrorInfo}";
}
?>

19~28行目: check.php と同じですが、ここで、POST メソッドで送られてきた ticket と、セッション変数に格納されている ticket を確認します。
$_POST[‘ticket’]、$_SESSION[‘ticket’] 、どちらか一方でもない場合 → form.php へ誘導
$_POST[‘ticket’] と $_SESSION[‘ticket’] の値が一致しない場合 → form.php へ誘導

73, 74行目: メール送信が完了したら、セッションを破棄します。