📫

スクラッチで問い合わせフォームを実装 javascript + php

2023/12/12に公開
2

目的物

  • 静的LPに集客用の問い合わせフォーム
  • ページ遷移せずに送信&thanks画面表示
  • 問い合わせ内容をメールで送信
  • XSS攻撃対策、CSRF対策、スパム対策(recaptcha)

basic html

contact.php
<form id="contact-form">
    <label for="company">会社名</label>
    <input type="text" name="company" id="company" required>
    <span class="error-message" id="error-company"></span>

    <label for="name">お名前</label>
    <input type="text" name="name" id="name" required>
    <span class="error-message" id="error-name"></span>

    <label for="tel">電話番号</label>
    <input type="tel" name="tel" id="tel" required pattern="\d{10,11}">
    <span class="error-message" id="error-tel"></span>

    <label for="email">メールアドレス</label>
    <input type="email" name="email" id="email" required>
    <span class="error-message" id="error-email"></span>

    <label for="category">お問い合わせの種類</label>
    <select name="category" id="category" required>
        <option disabled selected value="">選択してください</option>
        <option value="hogehogeの相談">hogehogeの相談</option>
        <option value="hogehogeのリクエスト">hogehogeのリクエスト</option>
        <option value="その他">その他</option>
    </select>
    <span class="error-message" id="error-category"></span>

    <label for="message">お問い合わせ内容</label>
    <textarea name="message" id="message" cols="30" rows="10" required></textarea>
    <span class="error-message" id="error-message"></span>

    <input type="radio" name="usertype" id="usertype_a" value="ユーザータイプA" checked>
    <label for="usertype_a">ユーザータイプA</label>
    <input type="radio" name="usertype" id="usertype_b" value="ユーザータイプB">
    <label for="usertype_b">ユーザータイプB</label>

    <input type="checkbox" id="privacy-policy" required>
    <label for="privacy-policy"><a href="https://sample.com/privacy-policy/" target="_blank">個人情報保護方針</a>に同意する</label>
    <span class="error-message" id="error-privacy"></span>
    
    <input type="submit" id="submit-button" value="送信する">
</form>
<div id="form-response">
    <!-- 送信後のthanks表示用 -->
</div>

ページ遷移せずに送信&thanks画面表示

通常のフォーム送信はactionで

<form action="send_mail.php" method="post">

遷移せずに送信はjsで

html側 contact.php
<form id="contact-form">
js側 main.js
document
    .getElementById('contact-form')
    .addEventListener('submit', function (e) {
      e.preventDefault() // 通常のフォーム送信を防止
      if (!formValidation()) return //バリデーション

      const formData = new FormData(this)

      fetch('/send_mail.php', {
        method: 'POST',
        body: formData,
      })
        .then((response) => {
          return response.text()
        })
        .then((data) => {
          // thanksの内容を差し込む
          document.getElementById('form-response').innerHTML = data
          document.getElementById('contact-form').style.display = 'none'
        })
        .catch((error) => {
          console.error('Error:', error)
          document.getElementById('form-response').innerHTML = error.message
        })
    })

サンプルはfetch'/send_mail.php'のレスポンスでthanksの内容を返すようにしているので、それを#form-responseに突っ込んで表示した
#form-responseに予めthanksの表示を作っておいて、fetch'/send_mail.php'のレスポンスによってcss classで表示・非表示を制御するのもよい

問い合わせ内容をメールで送信

send_mail.php
<?php
if($_SERVER["REQUEST_METHOD"] == "POST"){
    $usertype = $_POST['usertype'];
    $company = $_POST['company'];
    $name = $_POST['name'];
    $tel = $_POST['tel'];
    $email = $_POST['email'];
    $category = $_POST['category'];
    $message = $_POST['message'];

    $to = 'hoge@sample.com'; // 送信先のメールアドレス
    $subject = 'Webサイトからのお問い合わせ';
    $body = "会社名: {$company}\nお名前: {$name}\n電話番号: {$tel}\nメールアドレス: {$email}\nお問い合わせの種類: {$category}\nお問い合わせ内容:\n{$message}";

    $headers = "From: webmaster@example.com"; // 送信元のメールアドレス

    mail($to, $subject, $body, $headers);
    echo "お問い合わせありがとうございます。";
}
?>

これでベースのフォームができた

XSS攻撃対策

xss攻撃とは

XSS攻撃(クロスサイトスクリプティング攻撃)は、ウェブセキュリティの脆弱性を利用する一種の攻撃手法です。この攻撃では、攻撃者は悪意のあるスクリプトをウェブページに注入し、他のユーザーがそのページを閲覧するときにそのスクリプトが実行されるようにします。この結果、攻撃者は被害者のブラウザでスクリプトを実行し、機密情報を盗んだり、ユーザーのセッションを乗っ取ったりすることができます。

XSS攻撃には主に三つのタイプがあります:

  1. 反射型XSS(Reflected XSS):このタイプの攻撃では、攻撃者は悪意のあるスクリプトを含むリンクを作成し、被害者にそのリンクをクリックさせます。被害者がリンクをクリックすると、悪意のあるスクリプトがウェブサーバーに送信され、その後ブラウザに戻って実行されます。

  2. 格納型XSS(Stored XSS):ここでは、悪意のあるスクリプトがウェブサーバー上のデータベースに「格納」されます。例えば、掲示板に悪意のあるコメントを投稿することで、そのコメントを閲覧するすべてのユーザーに対してスクリプトが実行されることになります。

  3. DOMベースXSS(DOM-based XSS):この攻撃では、ウェブアプリケーションのクライアントサイドスクリプト(通常はJavaScript)を通じて脆弱性を悪用します。ウェブページのDOM(Document Object Model)が変更され、悪意のあるスクリプトが実行されることになります。

XSS攻撃を防ぐためには、ウェブアプリケーションで入力の検証とサニタイズ(無害化)を徹底し、信頼できるコンテンツのみを許可することが重要です。また、ブラウザのセキュリティ機能(例えば、Content Security Policy)を活用することも効果的です。

XSS攻撃対策するにはクライアントから受け取ったコンテンツをサニタイズ(無害化)すること
php側で受け取る値をすべてhtmlspecialcharsを通しておく
htmlspecialcharsはすべての値を通すではなく、HTML表示する場合に使うもので、 <、>、&、"、')などの記号を適切にエスケープして安全にHTML出力するためのものです。
今回はユーザー入力をthanks画面のhtmlに出力もしなければ、メール本文もHTMLではなくプレーンテキストのため、htmlspecialchars使わなくてよい。

phpのhtmlspecialcharsについて

PHPのhtmlspecialchars関数は、特定の文字をHTMLエンティティに変換するために使用されます。これにより、HTMLページにユーザーからの入力を安全に表示でき、XSS攻撃などのセキュリティリスクを防ぐことができます。

htmlspecialchars関数は以下のように使用されます:

$new_string = htmlspecialchars($original_string, flags, character_encoding, double_encode);

ここで、各パラメーターは次のように定義されます:

  1. $original_string: 変換される文字列。
  2. flags: 文字の変換方法を指定するフラグ。デフォルトは ENT_COMPAT | ENT_HTML401 です。
  3. character_encoding: 文字列のエンコーディングを指定します。デフォルトは ini_get("default_charset") で、通常は UTF-8 です。
  4. double_encode: 既にエンティティ化された文字を再度エンティティ化するかどうか。デフォルトは true です。

例:

$original_string = "<a href='test'>Test</a>";
$escaped_string = htmlspecialchars($original_string);
echo $escaped_string;

この例では、<>'"、および&がそれぞれ&lt;&gt;&#039;&quot;&amp;に変換されます。結果として、出力される文字列はブラウザにおいてリテラルなテキストとして表示され、HTMLタグとして解釈されません。

注意点:

  • htmlspecialcharsはHTMLタグをエスケープするためのものであり、データベースへの挿入には適していません。データベースへの挿入の前には、適切なデータベースエスケープ関数(例えば、PDOのプリペアドステートメント)を使用するべきです。
  • JavaScriptコードやCSSスタイルのコンテキストでは、htmlspecialcharsの使用だけでは不十分な場合があります。これらのコンテキストでは、追加のサニタイズが必要になることがあります。

HTML出力もしない、データベースへの保存も行わない、プレーンテキストのメール送信のみなので、XSS攻撃対策については結論、あまりやることはない!
(違ったらご指摘ください)

CSRF対策

CSRFとは

CSRF(クロスサイトリクエストフォージェリ)は、悪意のある攻撃者がユーザーのブラウザを利用して、そのユーザーがログインしている別のウェブサイト上で、ユーザーが意図しない操作を行わせる攻撃です。CSRF攻撃を防ぐための対策は、ウェブアプリケーションのセキュリティを強化するために重要です。

CSRF対策の主要な方法は以下のとおりです:

  1. トークンの使用: サーバーは各セッションやフォームごとに一意のトークン(CSRFトークン)を生成し、クライアントに送信します。ユーザーがフォームを送信する際に、このトークンも一緒に送信される必要があります。サーバーは送信されたトークンがセッションに保存されたトークンと一致することを確認し、そうでなければリクエストを拒否します。

  2. SameSite Cookie属性の設定: SameSite Cookie属性を設定することで、ブラウザはクロスサイトリクエストに対してCookieを送信しないようになります。例えば、SameSite=Lax または SameSite=Strict を設定することができます。

  3. リファラの検証: ウェブアプリケーションはHTTPリファラヘッダを確認して、リクエストが信頼できるソース(同じオリジン)から来ているかを確認します。

  4. カスタムヘッダの使用: AJAXリクエストにカスタムHTTPヘッダを追加し、サーバー側でこのヘッダの存在を確認することで、正規のリクエストと区別します。

  5. 二要素認証: 二要素認証(2FA)を使用することで、重要な操作に対する追加のセキュリティ層を提供することができます。

これらの対策は、特にフォームを使用したウェブアプリケーションにおいて重要です。CSRFトークンの実装は、ウェブアプリケーションフレームワークによって異なる場合がありますが、多くの現代のフレームワークはCSRF対策を標準で提供しています。常に最新のセキュリティプラクティスを遵守し、ウェブアプリケーションを定期的にアップデートして保護することが重要です。

対策方法は送信するクライアント側でsession作成し、フォーム送信データとともにcsrf_tokenも送信し、受け取るサーバサイド側のsend_mail.php内のsessionと照らし合わせて、一致しない場合はリクエストを拒否することで対策できる

contact.php
<!-- クライアントサイドの先頭にsession開始し、CSRFトークンがまだ存在しない場合のみ生成 -->
<?php
session_start();
if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<!DOCTYPE html>
...

<form id="contact-form">
...
<!-- form内にcsrf_tokenも渡すように -->
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>">
...
</form>
...
</html>
send_mail.php
<!-- サーバーサイドもsession開始する必要がある -->
<?php
session_start();

function isValidCSRFToken($token)
{
    return $token === $_SESSION['csrf_token'];
}

if ($_SERVER["REQUEST_METHOD"] == "POST") {
    if (!isValidCSRFToken($_POST['csrf_token'])) {
        http_response_code(400);
        echo "無効なCSRFトークンです。";
	return;
    } 
    
    // メール送信処理 ...
}
contact.php
<!-- 悪い例 -->
<?php
session_start();
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
?>
<!DOCTYPE html>
...

スパム対策(recaptcha)

reCAPTCHAとは

reCAPTCHAは、Googleが提供するCAPTCHAシステムの一つです。CAPTCHA(完全自動公開チューリングテスト)とは、人間と機械(特にボット)を区別するためのテストで、ウェブサイトのセキュリティを強化するために広く使用されています。reCAPTCHAの目的は、自動化されたソフトウェアによる悪意のある活動を防ぎつつ、合法的なユーザーのアクセスを許可することです。

reCAPTCHAの主要な特徴は次のとおりです:

  1. イメージベースの認証: 古いバージョンのreCAPTCHAでは、ユーザーに歪んだテキストや数字を読み取って入力させることで、自動化ツールと人間を区別していました。

  2. reCAPTCHA v2: このバージョンでは、「私はロボットではありません」チェックボックス("I'm not a robot" Checkbox)が導入されました。ユーザーがチェックボックスをクリックすると、様々なシグナルに基づいてユーザーが人間かどうかを判断します。場合によっては、追加の課題(例えば、道路標識や店舗の写真を識別する)が提示されることもあります。

  3. reCAPTCHA v3: このバージョンでは、ユーザーインタラクションに基づいてスコアを生成し、ウェブサイトのオーナーがこのスコアを用いて人間とボットを区別できます。reCAPTCHA v3は非表示で動作し、ユーザー体験を妨げることなくセキュリティを提供します。

reCAPTCHAは、フォームの送信、ログイン、コメントの投稿など、ウェブサイト上のさまざまなアクションに使用されます。このシステムは、スパムや自動化されたデータ収集を防ぐのに効果的ですが、ユーザビリティとセキュリティのバランスを取る必要があります。また、GoogleはreCAPTCHAを通じて機械学習アルゴリズムのトレーニングデータを収集していることが知られています。

Google reCAPTCHA で「新しいサイトを登録する」
https://www.google.com/recaptcha/admin/create

  • ラベル: サイト名など(分かればいいので何でもいい)
  • reCAPTCHAタイプ: v3 (v2はうっとうしいのでやめよう)
  • ドメイン: 本番ドメインや、ローカル開発のlocalhostを入れておく

作成後、サイトキーとシークレットキーを取得できる

  • サイトキー: クライアント側で使うもの、調べりゃすぐわかるものなので公開鍵のようなもの
  • シークレットキー: サーバサイドで使うもの、絶対公開しちゃだめ

クライアント側でscript読み込む、formにreCAPTCHA用のinputを追加

contact.php
<head>
...
    <script src="./main.js"></script>
    <script src="https://www.google.com/recaptcha/api.js?render=<サイトキー>"></script>
</head>
...
<form id="contact-form">
<input type="hidden" name="recaptcha_response" id="recaptchaResponse">
<>
...

クライアント側のjsでformの#recaptchaResponseに値をセットする

document.addEventListener('DOMContentLoaded', () => {
  grecaptcha.ready(function () {
    grecaptcha
      .execute('<サイトキー>', {
        action: 'submit',
      })
      .then(function (token) {
        document.getElementById('recaptchaResponse').value = token
      })
  })
})

サーバサイドで検証

send_mail.php
// reCAPTCHA検証関数
function verifyRecaptcha($recaptchaResponse, $secretKey)
{
    $url = 'https://www.google.com/recaptcha/api/siteverify';
    $data = http_build_query([
        'secret' => $secretKey,
        'response' => $recaptchaResponse
    ]);

    $options = [
        'http' => [
            'header' => "Content-type: application/x-www-form-urlencoded\r\n",
            'method' => 'POST',
            'content' => $data
        ]
    ];

    $context = stream_context_create($options);
    $response = file_get_contents($url, false, $context);
    return json_decode($response)->success;
}


if ($_SERVER["REQUEST_METHOD"] == "POST") {
    if (!verifyRecaptcha($_POST['recaptcha_response'], 'シークレットキー')) {
        http_response_code(401);
        echo "reCAPTCHAの認証に失敗しました。";
	return;
    }
 // メール送信処理 ...
}

完成品

以上の流れに、js側とphp側両方バリデーションをつけた全体像

contact.php
<?php
session_start();
// CSRFトークンがまだ存在しない場合のみ生成
if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./js/main.js"></script>
    <script src="https://www.google.com/recaptcha/api.js?render=<サイトキー>"></script>
</head>
<body>
    <form id="contact-form">
        <label for="company">会社名</label>
        <input type="text" name="company" id="company" required>
        <span class="error-message" id="error-company"></span>

        <label for="name">お名前</label>
        <input type="text" name="name" id="name" required>
        <span class="error-message" id="error-name"></span>

        <label for="tel">電話番号</label>
        <input type="tel" name="tel" id="tel" required pattern="\d{10,11}">
        <span class="error-message" id="error-tel"></span>

        <label for="email">メールアドレス</label>
        <input type="email" name="email" id="email" required>
        <span class="error-message" id="error-email"></span>

        <label for="category">お問い合わせの種類</label>
        <select name="category" id="category" required>
            <option disabled selected value="">選択してください</option>
            <option value="hogehogeの相談">hogehogeの相談</option>
            <option value="hogehogeのリクエスト">hogehogeのリクエスト</option>
            <option value="その他">その他</option>
        </select>
        <span class="error-message" id="error-category"></span>

        <label for="message">お問い合わせ内容</label>
        <textarea name="message" id="message" cols="30" rows="10" required></textarea>
        <span class="error-message" id="error-message"></span>

        <input type="radio" name="usertype" id="usertype_a" value="ユーザータイプA" checked>
        <label for="usertype_a">ユーザータイプA</label>
        <input type="radio" name="usertype" id="usertype_b" value="ユーザータイプB">
        <label for="usertype_b">ユーザータイプB</label>

        <input type="checkbox" id="privacy-policy" required>
        <label for="privacy-policy"><a href="https://sample.com/privacy-policy/"
                target="_blank">個人情報保護方針</a>に同意する</label>
        <span class="error-message" id="error-privacy"></span>

        <!-- CSRF対策 -->
        <input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>">
        <!-- google recaptcha用 -->
        <input type="hidden" name="recaptcha_response" id="recaptchaResponse">

        <input type="submit" id="submit-button" value="送信する">
    </form>
    <div id="form-response" class="form-response">
	 <!-- エラーレスポンス表示用 -->
    </div>
    <div id="form-thanks" class="form-thanks">
	<h2>お問い合わせありがとうございました</h2>
    </div>
</body>
</html>
/js/main.js
document.addEventListener('DOMContentLoaded', () => {
  grecaptcha.ready(function () {
    grecaptcha
      .execute('<サイトキー>', {
        action: 'submit',
      })
      .then(function (token) {
        document.getElementById('recaptchaResponse').value = token
      })
  })

  handleForm()
})

function handleForm() {
  document
    .getElementById('contact-form')
    .addEventListener('submit', function (e) {
      e.preventDefault() // 通常のフォーム送信を防止
      if (!formValidation()) return // バリデーションエラーがあれば送信しない

      const formData = new FormData(this)

      fetch('/send_mail.php', {
        method: 'POST',
        body: formData,
      })
        .then((response) => {
          console.log({ response })
          if (response.status !== 200) {
            return response.text().then((text) => {
              throw new Error(
                `Error: (${response.status}) ${response.statusText}\n${text}`
              )
            })
          }
          return response.text()
        })
        .then((data) => {
          console.log({ data })
          document.getElementById('form-thanks').style.display = 'flex'
          document.getElementById('contact-form').style.display = 'none'
        })
        .catch((error) => {
          console.error('Error:', error)
          const formResponse = document.getElementById('form-response')
          formResponse.innerHTML = error.message.replace(/\n/g, '<br />')
          formResponse.style.display = 'flex'
        })
    })
}

function formValidation() {
  let isValid = true

  // 会社名の検証
  const company = document.getElementById('company')
  const errorCompany = document.getElementById('error-company')
  if (!company.value.trim()) {
    errorCompany.textContent = '会社名を入力してください。'
    isValid = false
  } else if (company.value.length > 100) {
    errorCompany.textContent = 'お名前は100文字以下で入力してください。'
    isValid = false
  } else {
    errorCompany.textContent = ''
  }

  // お名前の検証
  const name = document.getElementById('name')
  const errorName = document.getElementById('error-name')
  if (!name.value.trim()) {
    errorName.textContent = 'お名前を入力してください。'
    isValid = false
  } else if (name.value.length > 100) {
    errorName.textContent = 'お名前は100文字以下で入力してください。'
    isValid = false
  } else {
    errorName.textContent = ''
  }

  // メールアドレスの検証
  const email = document.getElementById('email')
  const errorEmail = document.getElementById('error-email')
  const regex =
    /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
  if (!email.value.trim()) {
    errorEmail.textContent = 'メールアドレスを入力してください。'
    isValid = false
  } else if (!regex.test(email.value)) {
    errorEmail.textContent = 'メールアドレスの形式が正しくありません。'
    isValid = false
  } else {
    errorEmail.textContent = ''
  }

  // 電話番号の検証
  const tel = document.getElementById('tel')
  const errorTel = document.getElementById('error-tel')
  if (!tel.value.trim()) {
    errorTel.textContent = '電話番号を入力してください。'
    isValid = false
  } else if (!tel.value.match(/^\d{2,5}-?\d{1,4}-?\d{3,4}$/)) {
    errorTel.textContent = '電話番号の形式が正しくありません。'
    isValid = false
  } else {
    errorTel.textContent = ''
  }

  // お問い合わせの種類の検証
  const category = document.getElementById('category')
  const errorCategory = document.getElementById('error-category')
  if (!category.value.trim()) {
    errorCategory.textContent = 'お問い合わせの種類を選択してください。'
    isValid = false
  } else {
    errorCategory.textContent = ''
  }

  // お問い合わせ内容の検証
  const message = document.getElementById('message')
  const errorMessage = document.getElementById('error-message')
  if (!message.value.trim()) {
    errorMessage.textContent = 'お問い合わせ内容を入力してください。'
    isValid = false
  } else if (message.value.length > 5000) {
    errorMessage.textContent =
      'お問い合わせ内容は5000文字以下で入力してください。'
    isValid = false
  } else {
    errorMessage.textContent = ''
  }

  // プライバシーポリシーの検証
  const privacyPolicy = document.getElementById('privacy-policy')
  const errorPrivacyPolicy = document.getElementById('error-privacy')
  if (!privacyPolicy.checked) {
    errorPrivacyPolicy.textContent = 'プライバシーポリシーに同意してください。'
    isValid = false
  } else {
    errorPrivacyPolicy.textContent = ''
  }

  return isValid
}
send_mail.php

手間を省くために、お客さんに送信する問い合わせの自動返信にBCCでマスターにも送信するようにすればいいが、fromとbccが同じメールアドレスだと送信されないっぽいのでとりあえずbackupという名前で別々のメールアドレスになるように設定
また、php側のバリデーションはなるべくjs側と同じになるようにした

<?php
session_start();

function isValidCSRFToken($token)
{
    return $token === $_SESSION['csrf_token'];
}

function verifyRecaptcha($recaptchaResponse, $secretKey)
{
    $url = 'https://www.google.com/recaptcha/api/siteverify';
    $data = http_build_query([
        'secret' => $secretKey,
        'response' => $recaptchaResponse
    ]);

    $options = [
        'http' => [
            'header' => "Content-type: application/x-www-form-urlencoded\r\n",
            'method' => 'POST',
            'content' => $data
        ]
    ];

    $context = stream_context_create($options);
    $response = file_get_contents($url, false, $context);
    return json_decode($response)->success;
}

function validate($postData)
{
    $errors = [];

    $company = trim($postData['company']);
    if (empty($company)) $errors[] = "会社名を入力してください。";
    elseif (mb_strlen($company, 'UTF-8') > 100) $errors[] = "会社名は100文字以内で入力してください。";

    $name = trim($postData['name']);
    if (empty($name)) $errors[] = "お名前を入力してください。";
    elseif (mb_strlen($name, 'UTF-8') > 100) $errors[] = "お名前は100文字以内で入力してください。";

    $email = trim($postData['email']);
    if (empty($email)) $errors[] = "メールアドレスを入力してください。";
    elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = "メールアドレスの形式が正しくありません。";

    $tel = trim($postData['tel']);
    if (empty($tel)) $errors[] = "電話番号を入力してください。";
    elseif (!preg_match('/^\d{2,5}-?\d{1,4}-?\d{3,4}$/', $tel)) $errors[] = "電話番号の形式が正しくありません。";

    if (!isset($postData['category'])) {
        $errors[] = "お問い合わせの種類を選択してください。";
    } else {
        $category = trim($postData['category']);
        if (empty($category)) {
            $errors[] = "お問い合わせの種類を選択してください。";
        }
    }

    $message = trim($postData['message']);
    if (empty($message)) $errors[] = "お問い合わせ内容を入力してください。";
    elseif (mb_strlen($message, 'UTF-8') > 5000) $errors[] = "お問い合わせ内容は5000文字以内で入力してください。";

    $privacyPolicy = isset($postData['privacy-policy']) && $postData['privacy-policy'] === 'on';
    if (!$privacyPolicy) $errors[] = "プライバシーポリシーに同意してください。";

    return $errors;
}

function sendEmail($postData)
{
    $usertype = $postData['usertype'];
    $company = $postData['company'];
    $name = $postData['name'];
    $tel = $postData['tel'];
    $email = $postData['email'];
    $category = $postData['category'];
    $message = $postData['message'];

    $to = $email; // 送信先のメールアドレス
    $subject = 'LOG-3PLへのお問い合わせを受け付けました';
    $body = "{$company} {$name}様\n\nこの度はお問い合わせをいただき、誠にありがとうございます。\n以下の内容でお問い合わせを受け付けました。\n\n-------------------------------------------------\n[会社名({$usertype})] {$company}\n[お名前] {$name}\n[電話番号] {$tel}\n[メールアドレス] {$email}\n[お問い合わせの種類] {$category}\n[お問い合わせ内容]\n{$message}\n-------------------------------------------------\n\n弊社担当者が迅速に対応させていただきますので、少々お待ちいただけますと幸いです。\n今後ともLOG-3PLをよろしくお願いいたします。\n\n-------------------------------------------------\n";

    $fromEmail = "from@sample.com"; // 送信元のメールアドレス
    $masterEmail = "master@sample.com"; // 管理用のメールアドレス
    $backupEmail = "backup@sample.com"; // バックアップ用のメールアドレス
    $headers = "From: HOGEHOGE <$fromEmail>\r\n";
    $headers .= "BCC: $masterEmail, $backupEmail\r\n";

    return mail($to, $subject, $body, $headers);
}

if ($_SERVER["REQUEST_METHOD"] == "POST") {
    if (!isValidCSRFToken($_POST['csrf_token'])) {
        http_response_code(400);
        echo "無効なCSRFトークンです。";
    } else if (!verifyRecaptcha($_POST['recaptcha_response'], '<シークレットキー>')) {
        http_response_code(401);
        echo "reCAPTCHAの認証に失敗しました。";
    } else {
        $errors = validate($_POST);
        if (count($errors) > 0) {
            http_response_code(400);
            echo implode("\n", $errors);
            return;
        }
        if (sendEmail($_POST)) {
            http_response_code(200);
            echo "お問い合わせありがとうございます。";
        } else {
            http_response_code(500);
            echo "お問い合わせ送信できませんでした。";
        }
    }
} else {
    http_response_code(400);
    echo "不正なリクエストです。";
}

Discussion

ピン留めされたアイテム
rana_kualurana_kualu

・XSS攻撃対策

htmlspecialcharsはテキストをHTMLとして表示する際に使用するエスケープです。

メールの本文はHTMLではないので、htmlspecialcharsすべきではありません。
'$a > $b'などの文が文字化けします。

メールアドレスにも使用してはいけません。
この人に送れなくなります。

・CSRF対策

悪い例でcsrf_tokenが変わってしまう理由はifで存在確認を行っていないからであり、生成する場所は特に関係ありません。

・バリデーションについて

フロントではバリデーションしていますが、バックエンドでは行っていません。
これだとJSをパスしてsend_mail.phpに任意の文字列を送り付けることが可能です。

基本的にメール本文だけなのでたいした問題はありませんが、emailを書き替えることで複数人にSPAMをばらまくことが可能です。
というか、この正規表現では最初から複数人書けるな。

・電話番号の検証

伊豆大島の人が問い合わせすることができません。

松嶋松松嶋松

@rana_kualu さん、
詳細までレビューしていただきありがとうございました!!
ご指摘に従って一通り調査&修正しました!!!大変勉強になりました!!

XSS攻撃対策

htmlspecialcharsについて、今回はthanks画面上にhtmlとしてユーザー入力を出力しないし、メール本文もHTMLにしていないのでhtmlspecialcharsを使う必要がないんですね!!
プレーンテキストのメール送信なので、ほぼそのままにしてメールだけFILTER_VALIDATE_EMAILをかけるように修正してみました!

CSRF対策

記事の該当部分を加筆しました!
わかったつもりの自分が全然分かっていなかったでした!

メールアドレスの検証(JS側)

最初から複数メアド入力できるご指摘、なるほどと思いました。
また特殊なメールアドレスを受け付けるかどうかについて、改めて調べたらRFC準拠など奥深いと感じました。
PHP側もご指摘に従ってバリデーションを調べてみましたが、FILTER_VALIDATE_EMAILフィルタはRFC 5322に準拠しているなど、フロントのバリデーションもそれと同じようにするのは今の私に難しそうなので、一旦ブラウザ自動検証に相当する正規表現に変更しました。

/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;

一応'or'1'='1'--@sample.comには送れるようです^^A
面白い情報ありがとうございました!!

電話番号の検証(JS側)

5桁市外局番に対応するようjs側バリデーションの正規表現を/^\d{2,5}-?\d{1,4}-?\d{3,4}$/に修正しました!
考えすらしなかったことですので大変勉強になりました!

バリデーション

ご指摘に従って、バックエンド側のバリデーションを追加してみました!
なるべくjs側と同じバリデーションになるようにしたつもりですが、また何か気になるところあればご指摘ください!!!