【コピぺ可】セキュリティに配慮したメールフォームの作り方【PHP + HTML5 】

セキュリティに配慮したメールフォーム・お問い合わせフォームの作り方をご紹介します。バリデーション機能、レスポンシブ、郵便番号からの住所自動入力にも対応しています。一部修正していただければほとんどコピペでいけると思います。ソースの解説もございます。ご利用は自己責任で。

  • メールフォームを設置したい方
  • メールフォームのセキュリティの脅威について知りたい方

作成するメールフォームの概要

下記のようなメールフォームを作ります。

  • レスポンシブデザイン対応
  • Bootstrap4 + HTML5のバリデーション機能に対応
  • 郵便番号から住所を自動入力
  • 自動返信は無し
  • CSRF(クロスサイトリクエストフォージェリ / シーサフ)対策
  • クリックジャッキング対策
  • メールヘッダ・インジェクション対策

レスポンシブデザイン対応

Bootstrap 4を用いてレスポンシブデザインに対応します。

Introduction · Bootstrap 

Bootstrap 4 + HTML5のバリデーション機能

Bootstrap 4のFormsを用いてバリデーションを作成します。

Bootstrap 3などを使用しているサイトでは正常に動作しない場合がありますが、その際は別ページで作成してiframeで読み込めば問題無く使用できるかと思います。

Forms – Bootstrap 4.1 日本語リファレンス 

郵便番号から住所を自動入力

YubinBango」を使用して郵便番号入力時に住所を自動で挿入します。

GitHub – yubinbango/yubinbango 

自動返信は無し

セキュリティを考慮して自動返信機能は設けません

お問合せフォームを悪用する攻撃増加に関する注意喚起 | さくらインターネット

【セキュリティ ニュース】サイトの「問い合わせフォーム」を悪用する攻撃に警戒を):Security NEXT

CSRF(クロスサイトリクエストフォージェリ / シーサフ)対策

トークンを用いて他サイトからのリクエストを処理しないよう設定します。

CSRF(クロスサイトリクエストフォージェリ / シーサフ)とは?

クロスサイトリクエストフォージェリ(CSRF)とは、Webアプリケーションに存在する脆弱性、もしくはその脆弱性を利用した攻撃方法のことです。掲示板や問い合わせフォームなどを処理するWebアプリケーションが、本来拒否すべき他サイトからのリクエストを受信し処理してしまいます。

クロスサイトリクエストフォージェリ(CSRF) | トレンドマイクロ より引用

クリックジャッキング対策

他のドメインのサイトからのframe要素やiframe要素による読み込みを制限します。

クリックジャッキングとは

クリックジャッキングとは、Webサイト上に隠蔽・偽装したリンクやボタンを設置し、サイト訪問者を視覚的に騙してクリックさせるなど意図しない操作をするよう誘導させる手法です。

クリックジャッキングとは? 攻撃の仕組みと対策:株式会社 日立ソリューションズ・クリエイト より引用

メールヘッダ・インジェクション対策

メールヘッダに関する部分で外部入力が必要な項目(今回の場合はReply-to)の入力内容から改行コードを削除することで、メールヘッダインジェクション対策を行います。

メールヘッダインジェクションとは

ウェブアプリケーションの中には、利用者が入力した商品申し込みやアンケート等の内容を、特定のメールアドレスに送信する機能を持つものがあります。一般に、このメールアドレスは固定で、ウェブアプリケーションの管理者以外の人は変更できませんが、実装によっては、外部の利用者がこのメールアドレスを自由に指定できてしまう場合があります。このような問題を引き起こす脆弱性を「メールヘッダ・インジェクション」と呼び、それを悪用した攻撃を、「メールヘッダ・インジェクション攻撃」と呼びます。

安全なウェブサイトの作り方 – 1.8 メールヘッダ・インジェクション:IPA 独立行政法人 情報処理推進機構 より引用

メールフォームのデモ

下記のようなフォームが作成されます。デモなので実際に送信はされませんので適当に触ってください。

デモ

実際のソース

下記がソースです。オレンジ太字部分は任意で変更してください。

form.php

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail form</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<div class="container mt-3"> 
<!--Mail Form-->
<?php
session_start();
//クリックジャッキング対策
header( 'X-FRAME-OPTIONS: SAMEORIGIN' );
//HTML特殊文字エスケープ関数
function h( $str ) {
return htmlspecialchars( $str, ENT_QUOTES, 'UTF-8' );
}
//メールヘッダインジェクション対策のための改行削除関数
function i( $str ) {
return str_replace( array( "\r", "\n" ), '', $str );
}
//POSTされたデータを各変数に入れる
$your_name = isset( $_POST[ 'your_name' ] ) ? $_POST[ 'your_name' ] : NULL;
$your_email = isset( $_POST[ 'your_email' ] ) ? $_POST[ 'your_email' ] : NULL;
$your_zip = isset( $_POST[ 'your_zip' ] ) ? $_POST[ 'your_zip' ] : NULL;
$your_addr = isset( $_POST[ 'your_addr' ] ) ? $_POST[ 'your_addr' ] : NULL;
$your_inquiry = isset( $_POST[ 'your_inquiry' ] ) ? $_POST[ 'your_inquiry' ] : NULL;
//関数適用
$your_name = h( $your_name );
$your_email = h( $your_email );
$your_email = i( $your_email );
$your_zip = h( $your_zip );
$your_addr = h( $your_addr );
$your_inquiry = h( $your_inquiry );
//送信プログラム
if ( $_SERVER[ 'REQUEST_METHOD' ] != 'POST' ) { //送信ボタンを押して無い場合
//CSRF(クロスサイトリクエストフォージェリ シーサフ)対策としてユニークな文字列トークンをセッションに格納
$_SESSION[ 'token' ] = uniqid( '', true );
} else { //送信ボタンを押した場合
$token = $_POST[ 'token' ]; //送信されたトークンを変数に格納
if ( !( hash_equals( $token, $_SESSION[ 'token' ] ) && !empty( $token ) ) ) { //送信されたトークンがセッションと同じ値か比較して異なる場合
$sendcheck = "failed";
} else { //トークンが一致した場合
// メール設定
mb_language( "ja" );
mb_internal_encoding( "UTF-8" );
$to = "mail@example.com"; //メール送信先、複数の場合はカンマ区切り 
$subject = 'お問合せフォームよりメールが送信されました'; //メール件名
$from_mail = "noreply@example.com"; // 送信元メールアドレス(迷惑メールと判別されないよう設置サイトと同じドメインのメールアドレスにしてください
$from_name = mb_encode_mimeheader( "ほげほげ" ); // 送信者名
$reply_to = $your_email; //返信先
$cc = ''; //CC送信先があれば入力
$bcc = ''; //BCC送信先があれば入力
// メール本文設定
$message = "お問い合わせフォームより、下記内容でメッセージが送信されました。" . "\r\n\r\n";
$message .= "名前:" . $your_name . " \r\n";
$message .= "メールアドレス:" . $your_email . " \r\n";
$message .= "住所:〒" . $your_zip . " " . $your_addr . "\r\n\r\n";
$message .= "お問い合わせ内容:\r\n" . $your_inquiry . " \r\n";
// メールヘッダ設定
$header = "Content-Type: text/plain \r\n";
$header .= "Return-Path: " . $from_mail . " \r\n";
$header .= "From: " . $from_name . "<" . $from_mail . ">\r\n";
$header .= "Sender: " . $from_mail . " \r\n";
$header .= "Reply-To: " . $reply_to . " \r\n";
if ( !empty( $cc ) ) {
$header .= "Cc: " . $cc . " \r\n";
}
if ( !empty( $bcc ) ) {
$header .= "Bcc: " . $bcc . " \r\n";
}
$header .= "Organization: " . $from_name . " \r\n";
$header .= "X-Sender: " . $from_mail . " \r\n";
$header .= "X-Priority: 3 \r\n";
mb_send_mail( $to, $subject, $message, $header );
$sendcheck = "send";
}
}
?>
<?php if($sendcheck == 'send'){ ?>
<p class="d-block border border-success p-2">メッセージが送信されました。</p>
<p class="mx-2"> 内容確認のうえ、改めてこちらからご連絡差しあげますので、今しばらくお待ちください。</p>
<?php }elseif($sendcheck == 'failed'){ ?>
<p class="d-block border border-danger p-2">メッセージが送信できませんでした。</p>
<p class="mx-2"> フォームを複数タブで開いた場合、エラーが発生する可能性があります。
お手数ですがページを更新した上で再度お試しください。 </p>
<?php }else{ ?>
<form class="needs-validation h-adr" novalidate method="post">
<div class="mb-3">
<label>名前<span class="text-danger ml-1">※</span></label>
<input type="text" class="form-control" name="your_name" placeholder="名前" required>
<div class="invalid-feedback"> 入力してください </div>
</div>
<div class="mb-3">
<label>Eメール<span class="text-danger ml-1">※</span></label>
<input type="email" class="form-control" name="your_email" placeholder="Eメール" required>
<div class="invalid-feedback">入力してください</div>
</div>
<div class="mb-3 row">
<div class="col-md-3"> <span class="p-country-name" style="display:none;">Japan</span>
<label>郵便番号</label>
<input type="text" name="your_zip" maxlength="8" class="form-control mb-2 p-postal-code" placeholder="郵便番号">
</div>
<div class="col-md-9">
<label>住所</label>
<input type="text" name="your_addr" class="form-control p-region p-locality p-street-address p-extended-address" placeholder="住所">
</div>
</div>
<div class="mb-3">
<label>お問い合わせ内容<span class="text-danger ml-1">※</span></label>
<textarea class="form-control" name="your_inquiry" rows="5" placeholder="お問い合わせ内容" required></textarea>
<div class="invalid-feedback">入力してください</div>
</div>
<div class="mb-3">
<div class="custom-control">
<input class="custom-control-input" type="checkbox" value="" id="invalidCheck" required>
<label class="custom-control-label" for="invalidCheck">個人情報保護方針に同意する</label>
<div class="invalid-feedback mb-3">送信する前に同意する必要があります</div>
</div>
</div>
<input type="hidden" name="token" value="<?php echo $_SESSION['token'] ?>">
<div>
<button type="submit" class="btn btn-primary mb-3">送信</button>
</div>
</form>
<?php } ?>
<!--//Mail Form--> 
</div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script> 
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script> 
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script> 
<script src="https://yubinbango.github.io/yubinbango/yubinbango.js" charset="UTF-8"></script> 
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
var validation = Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
}, false);
})();
</script>
</body>
</html>

ソースの解説

順に解説して参ります。

1〜11行目 bootstrap.css の読み込み

bootsrap.css を読み込みます。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail form</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<div class="container mt-3"> 

12〜16行目 クリックジャッキング対策

header( 'X-FRAME-OPTIONS: SAMEORIGIN' ); を入れることで他ドメインのサイトからのframe要素やiframe要素による読み込みを制限します。

<!--Mail Form-->
<?php
session_start();
//クリックジャッキング対策
header( 'X-FRAME-OPTIONS: SAMEORIGIN' );

17〜20行目 HTML特殊文字エスケープ関数

HTML特殊文字のエスケープのための関数を作成します。

//HTML特殊文字エスケープ関数
function h( $str ) {
return htmlspecialchars( $str, ENT_QUOTES, 'UTF-8' );
}

21〜24行目 メールヘッダインジェクション対策のための改行削除関数

改行削除の関数を作成します。

//メールヘッダインジェクション対策のための改行削除関数
function i( $str ) {
return str_replace( array( "\r", "\n" ), '', $str );
}

25〜37行目 POST されたデータの格納と関数の適用

POST されたデータを変数に格納し、HTML特殊文字エスケープ関数と改行削除の関数を適用します。

送信内容の項目を増やしたい場合にもここに追加で記述する必要があります。

//POSTされたデータを各変数に入れる
$your_name = isset( $_POST[ 'your_name' ] ) ? $_POST[ 'your_name' ] : NULL;
$your_email = isset( $_POST[ 'your_email' ] ) ? $_POST[ 'your_email' ] : NULL;
$your_zip = isset( $_POST[ 'your_zip' ] ) ? $_POST[ 'your_zip' ] : NULL;
$your_addr = isset( $_POST[ 'your_addr' ] ) ? $_POST[ 'your_addr' ] : NULL;
$your_inquiry = isset( $_POST[ 'your_inquiry' ] ) ? $_POST[ 'your_inquiry' ] : NULL;
//関数適用
$your_name = h( $your_name );
$your_email = h( $your_email );
$your_email = i( $your_email );
$your_zip = h( $your_zip );
$your_addr = h( $your_addr );
$your_inquiry = h( $your_inquiry );

38〜46行目 クロスサイトリクエストフォージェリ対策

uniqid( '', true ) でユニークなランダム文字列を生成してトークンとしてセッションに格納します。

送信ボタンを押した際にPOSTされたトークンとセッションに格納されたトークンが同じ値か比較します。

//送信プログラム
if ( $_SERVER[ 'REQUEST_METHOD' ] != 'POST' ) { //送信ボタンを押して無い場合
//CSRF(クロスサイトリクエストフォージェリ / シーサフ)対策としてユニークな文字列トークンをセッションに格納
$_SESSION[ 'token' ] = uniqid( '', true );
} else { //送信ボタンを押した場合
$token = $_POST[ 'token' ]; //送信されたトークンを変数に格納
if ( !( hash_equals( $token, $_SESSION[ 'token' ] ) && !empty( $token ) ) ) { //送信されたトークンがセッションと同じ値か比較して異なる場合
$sendcheck = "failed";
} else { //トークンが一致した場合

47〜56行目 送信メール設定

主に編集するのはこの箇所になります。

メールの宛先、件名、From、送信者名、CC、BCCを任意で入力します。Reply-to には送信者が入力したメールアドレスが挿入されます。

// メール設定
mb_language( "ja" );
mb_internal_encoding( "UTF-8" );
$to = "mail@example.com"; //メール送信先、複数の場合はカンマ区切り 
$subject = 'お問合せフォームよりメールが送信されました'; //メール件名
$from_mail = "noreply@example.com"; // 送信元メールアドレス(迷惑メールと判別されないよう設置サイトと同じドメインのメールアドレスにしてください
$from_name = mb_encode_mimeheader( "ほげほげ" ); // 送信者名
$reply_to = $your_email; //返信先
$cc = ''; //CC送信先があれば入力
$bcc = ''; //BCC送信先があれば入力

57〜62行目 メール本文設定

送信されるメールの本文です。任意で編集してください。項目を追加する場合もここに追記が必要です。

 // メール本文設定
$message = "お問い合わせフォームより、下記内容でメッセージが送信されました。" . "\r\n\r\n";
$message .= "名前:" . $your_name . " \r\n";
$message .= "メールアドレス:" . $your_email . " \r\n";
$message .= "住所:〒" . $your_zip . " " . $your_addr . "\r\n\r\n";
$message .= "お問い合わせ内容:\r\n" . $your_inquiry . " \r\n";

63〜77行目 メールヘッダ設定

メールヘッダ部分です。ここは特に編集する必要はないと思います。

//メールヘッダ設定
$header = "Content-Type: text/plain \r\n";
$header .= "Return-Path: " . $from_mail . " \r\n";
$header .= "From: " . $from_name . "<" . $from_mail . ">\r\n";
$header .= "Sender: " . $from_mail . " \r\n";
$header .= "Reply-To: " . $reply_to . " \r\n";
if ( !empty( $cc ) ) {
$header .= "Cc: " . $cc . " \r\n";
}
if ( !empty( $bcc ) ) {
$header .= "Bcc: " . $bcc . " \r\n";
}
$header .= "Organization: " . $from_name . " \r\n";
$header .= "X-Sender: " . $from_mail . " \r\n";
$header .= "X-Priority: 3 \r\n";

78〜82行目 メール送信

mb_send_mailでメールを送信します。

mb_send_mail( $to, $subject, $message, $header );
$sendcheck = "send";
}
}
?>

83〜85行目 送信済メッセージ表示

メール送信後に表示するメッセージです。任意で編集してください。

<?php if($sendcheck == 'send'){ ?>
<p class="d-block border border-success p-2">メッセージが送信されました。</p>
<p class="mx-2"> 内容確認のうえ、改めてこちらからご連絡差しあげますので、今しばらくお待ちください。</p>

86〜90行目 送信エラーメッセージ表示

トークンが一致しなかった場合のエラーメッセージです。お問い合わせフォームのページを別タブで開いてしまうと、セッションに新しいトークンが格納されてしまうため、元のページから送信しようとするとトークンが不一致となりエラーが発生します。そんなことやる人いないでしょうけど。。

<?php }elseif($sendcheck == 'failed'){ ?>
<p class="d-block border border-danger p-2">メッセージが送信できませんでした。</p>
<p class="mx-2"> フォームを複数タブで開いた場合、エラーが発生する可能性があります。
お手数ですがページを更新した上で再度お試しください。 </p>
<?php }else{ ?>

91〜123行目 メールフォーム

HTMLのフォーム部分です。任意で編集してください。必須の項目にはrequiredを付けます。

<form class="needs-validation h-adr" novalidate method="post">
<div class="mb-3">
<label>名前<span class="text-danger ml-1">※</span></label>
<input type="text" class="form-control" name="your_name" placeholder="名前" required>
<div class="invalid-feedback"> 入力してください </div>
</div>
<div class="mb-3">
<label>Eメール<span class="text-danger ml-1">※</span></label>
<input type="email" class="form-control" name="your_email" placeholder="Eメール" required>
<div class="invalid-feedback">入力してください</div>
</div>
<div class="mb-3 row">
<div class="col-md-3"> <span class="p-country-name" style="display:none;">Japan</span>
<label>郵便番号</label>
<input type="text" name="your_zip" maxlength="8" class="form-control mb-2 p-postal-code" placeholder="郵便番号">
</div>
<div class="col-md-9">
<label>住所</label>
<input type="text" name="your_addr" class="form-control p-region p-locality p-street-address p-extended-address" placeholder="住所">
</div>
</div>
<div class="mb-3">
<label>お問い合わせ内容<span class="text-danger ml-1">※</span></label>
<textarea class="form-control" name="your_inquiry" rows="5" placeholder="お問い合わせ内容" required></textarea>
<div class="invalid-feedback">入力してください</div>
</div>
<div class="mb-3">
<div class="custom-control">
<input class="custom-control-input" type="checkbox" value="" id="invalidCheck" required>
<label class="custom-control-label" for="invalidCheck">個人情報保護方針に同意する</label>
<div class="invalid-feedback mb-3">送信する前に同意する必要があります</div>
</div>
</div>

124〜131行目 トークンのPOST

セッションに格納したトークンをinput type="hidden"でPOSTします。

<input type="hidden" name="token" value="<?php echo $_SESSION['token'] ?>">
<div>
<button type="submit" class="btn btn-primary mb-3">送信</button>
</div>
</form>
<?php } ?>
<!--//Mail Form--> 
</div>

132〜135行目 js 読み込み

jquery.js 、popper.js、 bootstrap.js、 yubinbango.jsを読み込みます。

<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script> 
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script> 
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script> 
<script src="https://yubinbango.github.io/yubinbango/yubinbango.js" charset="UTF-8"></script> 

136〜154行目 バリデーションの設定

バリデーションで不備があった場合、POSTしないよう制御。

<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
var validation = Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
}, false);
})();
</script>
</body>
</html>

以上です。

多分大丈夫ですがあくまで自己責任でご使用ください。何かおかしいところあればメッセージ頂けると助かります。

記事がお役に立ちましたらシェアお願いします!