静的サイトのお問い合わせフォームを本気でセキュリティ対策してみた

静的サイトのお問い合わせフォームに実装したセキュリティ対策

目次

はじめに

最近、弊社ではクライアントの静的サイトを構築する機会が続いています。

弊社では、WordPressを使ったホームページの制作を多く手がけていますが、クライアントのご要望やサイトの用途に応じて、HTML、CSS、JavaScriptを中心とした静的サイトも制作しています。

今回制作したホームページも、ホームページ本体はHTML、CSS、JavaScriptによる静的サイトとして構築しています。ただし、お問い合わせフォームを設置する際にはサーバー側での処理が必要になるため、これらに加えてPHPを使用しています。

WordPressでは、Contact Form 7などのフォームプラグインや、WordPress標準のフォームブロックなどを利用して、比較的簡単にお問い合わせフォームを設置できます。一方、静的サイトの場合は、フォーム処理、入力値の検証、CSRF対策、セッション管理、メール送信などを、私たち自身で設計・実装する必要があります。

そこで今回は、実際に制作した静的サイトのお問い合わせフォームを例に、私たちがどのようなセキュリティ対策を行ったのかを、できるだけ分かりやすく、そして詳しくご紹介いたします。

静的サイトと動的サイトとは?

静的サイトとは、あらかじめ用意されたHTMLファイルなどをWebサーバーから配信するホームページです。一方、動的サイトとは、ユーザーからのアクセスや入力などに応じて、サーバー側のプログラムが処理を行い、その結果を生成して表示するホームページです。WordPressはPHPやデータベースを利用してページを生成するため、一般的には動的サイトに分類されます。

この記事について

私は決してセキュリティの専門家ではありません。また、セキュリティに関する情報を常に追いかけているわけでもなく、どちらかといえばセキュリティには疎い方だと思います。

今回のフォームについても、必要な情報を調べ、AIの力も借りながら、私自身が設計・制作したものです。そのため、この実装が100%安全であると保証するものではありません。

そもそも、セキュリティに「100%完璧」というものはありません。新しい脆弱性が発見されることもありますし、攻撃手法も変化します。

この記事は、「これを実装すれば安全」という完成されたセキュリティ仕様書ではなく、実際のホームページ制作の中で、静的サイトのお問い合わせフォームをどのように考え、どのような対策を施したのかを記録した技術記事としてご覧ください。

今回のお問い合わせフォームの構成

今回のお問い合わせフォームは、HTML、CSS、JavaScript、PHPで制作しています。

contact.html

confirm.php

send.php

メール送信

thanks.html

contact.htmlでは、ユーザーがフォームに入力します。

confirm.phpでは、CSRFトークン、Honeypot、入力値などを確認し、入力値を検証したうえで、問題がなければセッションに保存します。

send.phpでは、セッションからデータを取得し、最終的な検証を行ったうえで、管理者宛メールと自動返信メールを送信します。メール送信処理が完了すると、thanks.htmlへリダイレクトします。

単純に「フォームから入力された内容をメールで送る」のではなく、確認ページから最終送信までの間にも、複数のセキュリティチェックを行う構成にしています。

今回実装したセキュリティ対策一覧

今回のフォームでは、主に以下の対策を実装しました。

  • CSRF対策
  • セッション固定攻撃への対策
  • セッションCookieの保護
  • XSS対策
  • サーバー側バリデーション
  • メールヘッダーインジェクション対策
  • セキュリティヘッダー
  • 二重送信への対策
  • Honeypotによるスパム対策

それでは、一つずつ見ていきます。

CSRF対策

CSRFとは?

CSRFは、Cross-Site Request Forgery(クロスサイト・リクエスト・フォージェリ)の略です。

悪意のあるホームページなどから、ユーザーのブラウザを利用して、本人が意図していないリクエストを、信頼されたホームページへ送信させる攻撃です。

例えば、ユーザーがあるホームページにログインした状態で、悪意のあるホームページを開いたとします。そのホームページから、ユーザーが意図していないフォーム送信などのリクエストを、別のホームページへ送信させられる可能性があります。

お問い合わせフォームでは、第三者によって意図しないデータが送信されることを防ぐために、送信元が正しいフォームからのリクエストなのかを確認する仕組みが必要になります。

そこで利用するのが「CSRFトークン」です。

CSRFトークンとは?

CSRFトークンとは、正規のフォームから送信されたリクエストであることを確認するために使用する、予測が難しいランダムな値です。

フォームを表示するときにサーバー側でランダムなトークンを生成し、それをセッションに保存します。

同時に、フォームにもそのトークンを埋め込んでおきます。

ユーザーがフォームを送信すると、サーバー側では、

  • セッションに保存されているCSRFトークン
  • フォームから送信されたCSRFトークン

の2つを比較します。

両方が一致すれば、正規のフォームから送信された可能性が高いと判断して処理を続行します。

逆に、トークンが存在しない、または一致しない場合は、不正なリクエストとして処理を中止します。

このように、攻撃者が正しいCSRFトークンを知らなければ、正しいリクエストを作りにくくするのがCSRFトークンの役割です。

CSRFトークンを生成する

今回のフォームでは、PHPのrandom_bytes()を利用してランダムな値を生成し、CSRFトークンとしてセッションに保存しています。

$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

random_bytes()は暗号学的に安全なランダムなバイト列を生成するために使用します。

生成した値をbin2hex()で16進数の文字列に変換することで、扱いやすい文字列としてCSRFトークンを保存しています。

送信されたトークンを検証する

フォームから送信されたCSRFトークンと、セッション側に保存されているトークンを比較します。

今回の実装では、単純な=====ではなく、hash_equals()を使用しています。

if (
    empty($session_token) ||
    empty($post_token) ||
    !hash_equals($session_token, (string)$post_token)
) {
    http_response_code(403);
    exit('不正なリクエストです。');
}

まず、セッション側とPOST側のトークンが存在することを確認します。

そのうえで、hash_equals()を使って2つの値を比較します。

トークンが存在しない場合や一致しない場合は、HTTPステータスコード403(Forbidden)を返し、処理を中止します。

最終送信時にも再度チェックする

今回のフォームでは、確認ページで一度チェックしたからといって、そのまま最終送信するのではありません。

最終的なメール送信処理を行うsend.phpでも、CSRFトークンを再度確認します。

フォームの途中でデータが変更されたり、不正なリクエストが送られたりする可能性を考え、最終的な処理を行う場所でも検証する構成にしています。

送信後にCSRFトークンを破棄する

メール送信が完了した後には、使用したCSRFトークンをセッションから削除します。

unset($_SESSION['csrf_token']);

これにより、送信済みのCSRFトークンをその後も使い続けにくい構成にしています。

今回のCSRF対策

今回のフォームでは、次のような流れでCSRF対策を行っています。

CSRFトークンを生成

セッションに保存

フォームにトークンを埋め込む

フォーム送信

セッション側とPOST側のトークンを比較

send.phpでも再度検証

メール送信

CSRFトークンを破棄

このように、単にCSRFトークンを用意するだけではなく、生成、保存、検証、破棄までを一連の流れとして実装しています。

セッション固定攻撃への対策

フォームの確認から最終送信までの状態を保持するため、PHPセッションを利用しています。

お問い合わせフォームでは、入力内容やCSRFトークンなどをセッションに保存しているため、セッションIDを適切に管理することが重要です。

セッション固定攻撃とは?

セッション固定攻撃(Session Fixation)とは、攻撃者があらかじめ用意したセッションIDをユーザーに使わせ、そのセッションIDを攻撃者自身も把握した状態にしておくことで、ユーザーのセッションを不正に利用しようとする攻撃です。

そのため、セッションIDを外部から勝手に指定されたものにしないことや、重要な処理の前後でセッションIDを変更することが重要になります。

CookieのみでセッションIDを利用する

今回のフォームでは、セッションIDをCookieだけで利用するように設定しています。

ini_set('session.use_only_cookies', '1');

PHPでは設定によっては、URLなどを利用してセッションIDを受け渡すことができます。

session.use_only_cookiesを有効にすることで、セッションIDをCookieのみで扱うようにしています。

これにより、URLなどにセッションIDを含める方式を避けることができます。

不正なセッションIDを受け入れにくくする

さらに、session.use_strict_modeも有効にしています。

ini_set('session.use_strict_mode', '1');

この設定を有効にすると、PHPはサーバー側でまだ作成されていないセッションIDを受け入れにくくなります。

これは、攻撃者が用意したセッションIDをユーザーに使わせるようなセッション固定攻撃への対策として重要な設定です。

セッションIDを再生成する

また、フォーム開始時には必要に応じて、

session_regenerate_id(true);

を使用してセッションIDを再生成します。

session_regenerate_id()は、現在のセッションデータを維持したまま、新しいセッションIDを発行するためのPHPの関数です。

セッションIDを再生成することで、以前使用していたセッションIDをそのまま使い続けることを避け、セッション固定攻撃への耐性を高めることができます。

trueを指定すると、古いセッションIDに対応するセッションデータも削除されます。

セッションIDの推測や盗用にも注意する

セッション管理で考えるべきリスクは、セッション固定攻撃だけではありません。

セッションIDが推測されたり、何らかの方法で盗まれたりすると、攻撃者がそのセッションを利用できる可能性があります。

そのため、今回のフォームでは、session.use_only_cookiessession.use_strict_modesession_regenerate_id()だけに頼るのではなく、次の章で説明するSecure、HttpOnly、SameSiteなどのCookie属性も組み合わせて、セッションを多層的に保護しています。

セッションCookieの保護

今回のフォームでは、セッションIDを保持するCookieに、いくつかのセキュリティ属性を設定しています。

session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'secure' => $is_https,
    'httponly' => true,
    'samesite' => 'Lax',
]);

Cookieには、セッションIDなどの重要な情報が保存される場合があります。

そのため、セッションIDそのものを適切に管理するだけでなく、そのセッションIDを保持するCookieについても、どのような条件で送信されるのかを適切に設定することが重要です。

Secure

Secure属性を設定すると、CookieはHTTPS接続時にのみ送信されます。

今回の実装では、

'secure' => $is_https,

としており、HTTPS接続時にSecure属性が有効になるようにしています。

これにより、セッションCookieがHTTP接続で送信されることを防ぎ、通信経路上でCookieが平文のHTTP通信に含まれることを避けます。

HttpOnly

HttpOnly属性を設定すると、JavaScriptからCookieを直接読み取ることができなくなります。

今回の設定では、

'httponly' => true,

としています。

これにより、JavaScriptからセッションCookieを取得されるリスクを低減します。

ただし、HttpOnlyはXSSそのものを防ぐ仕組みではありません。

あくまで、JavaScriptからCookieへアクセスされることを制限するための対策です。

SameSite

SameSite属性は、異なるサイトからのリクエストに対して、Cookieを送信する条件を制御します。

今回のフォームでは、

'samesite' => 'Lax',

としてLaxを設定しています。

SameSite=Laxでは、クロスサイトからのリクエストに対してCookieが無条件に送信されることを避けることができます。

これにより、悪意のあるホームページからユーザーのブラウザを利用してリクエストを送信させるCSRF攻撃に対する、防御層の一つとして機能します。

SameSiteだけでCSRF対策が完了するわけではない

ここで注意したいのは、SameSiteを設定したからといって、CSRF対策が完全にできるわけではないということです。

今回のフォームでは、SameSite=LaxによるCookie送信の制限に加えて、前章で説明したCSRFトークンによる検証も行っています。

CSRFトークンは、正規のフォームから送信されたリクエストであることを確認するための仕組みです。

一方、SameSiteは、ブラウザがクロスサイトのリクエストに対してCookieを送信する条件を制限する仕組みです。

つまり、

CSRFトークン
= リクエストに含まれるトークンをサーバー側で検証する

SameSite
= ブラウザによるCookieの送信条件を制限する

というように、それぞれ役割が異なります。

一つの対策だけに依存するのではなく、異なる仕組みを組み合わせることで、より多層的にCSRFなどのリスクを低減しています。

HSTS

セッションCookieの保護に加えて、ホームページへの接続自体についてもHTTPSを利用するように設定しています。

HTTPS接続時には、

header('Strict-Transport-Security: max-age=31536000');

を設定しています。

これはHSTS(HTTP Strict Transport Security)と呼ばれる仕組みです。

HSTSを利用すると、ブラウザに対して一定期間、ホームページへ接続するときにHTTPではなくHTTPSを使用するよう指示できます。

例えば、ユーザーがHTTPのURLへアクセスした場合でも、HSTSが有効になっていれば、ブラウザはHTTPSを使用して接続するようになります。

なお、HSTSはHTTPS接続で受信したレスポンスに設定する必要があります。

今回もHTTPS接続時にのみ、このヘッダーを送信するようにしています。

SecureとHSTSの違い

Secure属性とHSTSは、どちらもHTTPSに関係しますが、役割は異なります。

Secure属性は、

「セッションCookieをHTTPS接続時にのみ送信する」

ためのCookie側の設定です。

一方、HSTSは、

「ホームページへの接続そのものをHTTPSで行うようブラウザに指示する」

ための仕組みです。

つまり、

Secure

CookieをHTTPSでのみ送信

HSTS

ホームページへの接続をHTTPSにする

という違いがあります。

今回のフォームでは、Cookie側の保護としてSecure、JavaScriptからのCookieアクセスを制限するためにHttpOnly、クロスサイトでのCookie送信を制限するためにSameSiteを設定しています。

さらに、ホームページへの接続そのものについてはHSTSを設定しています。

このように、それぞれ異なる役割を持つセキュリティ対策を組み合わせることで、セッションを多層的に保護しています。

XSS対策

XSSは、Cross-Site Scripting(クロスサイト・スクリプティング)の略です。

ユーザーから送信された入力値などに悪意のあるスクリプトを混入させ、それがWebページ上で実行されてしまう脆弱性です。

例えば、ユーザーが入力した名前やお問い合わせ内容などを、そのままHTMLとしてWebページに出力してしまうと、入力内容によってはHTMLタグやJavaScriptとして解釈される可能性があります。

お問い合わせフォームでは、ユーザーが入力した内容を確認画面などに表示するため、入力値をHTMLへ出力する際には特に注意が必要です。

HTMLへ出力する際にエスケープする

今回の確認画面では、ユーザー入力をHTMLへ出力する際にエスケープするため、以下の関数を用意しています。

function e(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

htmlspecialchars()は、HTML上で特別な意味を持つ文字をHTMLエンティティへ変換するPHPの関数です。

例えば、<>&、引用符などを変換することで、入力値がHTMLのタグや属性として解釈されることを防ぎます。

今回の関数では、

  • ENT_QUOTESを指定して、シングルクォートとダブルクォートの両方を変換する
  • UTF-8を指定して、文字エンコーディングを明示する

ようにしています。

実際の確認画面では、例えば次のようにして出力します。

このように、ユーザーから送信された値をそのままHTMLへ出力するのではなく、出力する際にエスケープしています。

なぜ出力時にエスケープするのか

ユーザーから送信された入力値を「危険な文字が含まれていないか」という観点だけで判断するのは十分ではありません。

例えば、現在は安全に見える文字列でも、そのデータが別の場所でHTMLとして出力されると、問題になる可能性があります。

そのため、基本的にはユーザーから受け取ったデータを信用せず、HTMLへ出力するときに、出力先のコンテキストに応じた適切なエスケープを行うことが重要です。

バリデーションとエスケープは別の対策

ここで重要なのが、入力値のバリデーションと、出力時のエスケープは、それぞれ目的が異なるということです。

バリデーションは、

「この入力値を受け付けてよいか」

を確認するためのものです。

一方、エスケープは、

「この値をHTMLなどの中で安全に扱える形にする」

ためのものです。

例えば、名前欄に文字数制限を設けたり、メールアドレスの形式を確認したりするのはバリデーションです。

一方、確認画面に名前やお問い合わせ内容を表示するときにhtmlspecialchars()を使用するのは、出力時のエスケープです。

そのため、

「入力値をバリデーションしたから安全」

と考えるのではなく、

「受け取ったデータを適切に検証し、出力する際には適切にエスケープする」

というように、それぞれの段階で対策することが重要です。

出力する場所によって対策は異なる

なお、エスケープは「どこへ出力するか」によって方法が異なります。

今回の確認画面では、ユーザー入力をHTML本文として出力しているため、htmlspecialchars()を使用しています。

しかし、JavaScript、HTML属性、URL、CSSなど、異なるコンテキストへ値を出力する場合には、それぞれ適切な処理が必要になります。

そのため、「すべての入力値にhtmlspecialchars()を使えば、どのような場合でも安全」というわけではありません。

今回のフォームでは、実際にユーザー入力をHTMLへ出力する箇所に対して、適切なエスケープを行っています。

サーバー側バリデーション

HTMLのrequired属性やJavaScriptによる入力チェックだけでは、セキュリティ対策として十分ではありません。

ブラウザ上で実行されるHTMLやJavaScriptは、ユーザー自身が変更したり、無効化したりすることができるためです。

例えば、ブラウザの開発者ツールを利用してrequired属性を削除したり、JavaScriptによるチェックを回避したり、フォームを経由せずに直接HTTPリクエストを送信したりすることもできます。

そのため、今回のフォームでは、ブラウザ側だけでなく、サーバー側でも入力値を検証しています。

クライアント側とサーバー側の役割

ブラウザ側の入力チェックは、主にユーザーの入力ミスを減らし、フォームを使いやすくするために行います。

一方、サーバー側のバリデーションは、実際にサーバーが受け取ったデータを信頼してよいかを確認するために行います。

つまり、

ブラウザ側のチェック

ユーザーの利便性を高める

サーバー側のバリデーション

サーバーが受け取るデータを検証する

というように、それぞれ役割が異なります。

セキュリティ上重要な検証は、必ずサーバー側でも行う必要があります。

confirm.phpとsend.phpの両方で検証する

今回のフォームでは、confirm.phpだけでなく、最終的なメール送信処理を行うsend.phpでもバリデーションを行っています。

confirm.phpでは、フォームから送信されたデータを確認し、問題がなければ確認画面を表示します。

その後、send.phpでは、セッションに保存されたデータを取得し、最終的にメールを送信してよいデータなのかをもう一度確認します。

最終的な処理を行うsend.phpでも検証することで、確認処理を一度通過したデータだけを無条件に信頼する構成にしないようにしています。

Allowlist

入力値の検証では、「危険な値を除外する」という考え方だけでなく、「あらかじめ許可した値だけを受け入れる」というAllowlist(許可リスト)の考え方も利用しています。

例えば、問い合わせ種類については、

$allowed_inquiry_types = [
    '弊社事業についてのお問い合わせ',
    '求人についてのお問い合わせ',
    'その他のお問い合わせ',
];

として、あらかじめ許可した値だけを受け入れます。

このように選択肢が決まっている項目については、想定していない値を受け入れないようにしています。

文字数を制限する

名前、会社名、住所、お問い合わせ内容などには、それぞれ最大文字数を設定しています。

これにより、フォームで想定している範囲を大きく超えるデータを受け入れないようにしています。

文字数制限は、単に入力欄の表示上の都合だけではなく、サーバー側で受け付けるデータの範囲を明確にするという意味でも重要です。

Emailアドレスを検証する

メールアドレスについては、

filter_var($email, FILTER_VALIDATE_EMAIL)

を利用して、メールアドレスとして有効な形式かどうかを検証しています。

また、メールアドレスについても最大文字数を設定しています。

郵便番号や電話番号を検証する

郵便番号や電話番号についても、サーバー側で入力値を検証しています。

例えば、郵便番号であれば想定した形式になっているか、電話番号であれば許可している文字や形式になっているかなどを確認します。

このように、項目ごとに「どのような値を受け付けるのか」というルールを設定しています。

個人情報保護方針への同意を確認する

お問い合わせフォームでは、個人情報保護方針への同意も必須項目としています。

この同意についても、ブラウザ側のチェックだけに頼るのではなく、サーバー側で実際に同意された値が送信されているかを確認しています。

入力値を「信用しない」という考え方

サーバー側バリデーションで重要なのは、ユーザーから送信されたデータを最初から信用しないことです。

正常なユーザーが通常のフォームから送信したデータであっても、サーバー側では改ざんされたデータなのかどうかを直接判断することはできません。

そのため、

  • 許可された値か
  • 必須項目が入力されているか
  • 適切な形式になっているか
  • 想定した文字数以内か
  • 同意が必要な項目に同意しているか

などをサーバー側で確認し、想定していないデータは受け付けないようにしています。

今回のバリデーション

今回のフォームでは、次のような考え方で入力値を検証しています。

フォームから入力

ブラウザ側で入力チェック

confirm.phpでサーバー側バリデーション

セッションに保存

send.phpで最終バリデーション

問題がなければメール送信

ブラウザ側のチェックはユーザーの利便性のために行い、セキュリティ上必要な検証はサーバー側でも行う。

これが今回のフォームにおける基本的な考え方です。

岡山県浅口市寄島からの海(2026年8月19日撮影)

写真は2026年8月19日に岡山県浅口市寄島から撮影した海です。箸休めに……。

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

お問い合わせフォームでは、ユーザーが入力したメールアドレスを利用して、管理者宛のメールを送信します。

メール送信では、ユーザーから送信された入力値をメールヘッダーに使用する場合があるため、この部分も慎重に扱う必要があります。

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

メールヘッダーインジェクションとは、ユーザーが入力した値に改行コードなどを混入させることで、本来想定していないメールヘッダーを追加される攻撃です。

メールでは、From、To、Reply-To、Subjectなどの情報がヘッダーとして扱われています。

そのため、ユーザーから送信された値をそのままメールヘッダーに使用すると、意図しないヘッダーを追加されたり、メールの送信内容を改ざんされたりする可能性があります。

Fromを固定する

今回のフォームでは、ユーザーが入力したメールアドレスをFromには使用していません。

Fromは自社ドメインのメールアドレスに固定しています。

$from = 'hanako.yamada@aedi.jp';

ユーザー入力をFromとして直接利用しないことで、ユーザーが入力した値によって送信元メールアドレスを変更されることを防ぎます。

また、送信元を自社ドメインのメールアドレスに固定することで、メールの送信元を一貫して管理できるようにしています。

ユーザーのメールアドレスはReply-Toに使用する

では、ユーザーが入力したメールアドレスはどうするのかというと、Reply-Toに使用します。

Reply-Toを設定すると、管理者がメールに返信するときに、ユーザーが入力したメールアドレスを返信先として利用できます。

つまり、

From

AEDIのメールアドレス

Reply-To

フォームに入力されたユーザーのメールアドレス

という構成です。

ただし、Reply-Toもメールヘッダーの一つです。

そのため、「FromではなくReply-Toにすれば安全」というわけではありません。ユーザー入力をReply-Toに使用する場合も、適切な検証が必要です。

改行コードをチェックする

今回のフォームでは、メールアドレスに改行コードが含まれていないかを確認しています。

if (
    str_contains($email, "\r") ||
    str_contains($email, "\n")
) {
    $errors[] = 'Emailアドレスが正しくありません。';
}

メールヘッダーでは改行が特別な意味を持つため、ユーザー入力に改行コードが含まれている場合は受け付けません。

これにより、メールヘッダーに意図しない情報を追加されるリスクを低減します。

なお、メールアドレスについては、このチェックだけではなく、前章で説明したサーバー側バリデーションによる形式確認や文字数制限も行っています。

メール本文はプレーンテキストにする

今回のフォームでは、メール本文をHTMLメールではなく、プレーンテキストとして送信しています。

Content-Type: text/plain; charset=UTF-8

HTMLメールでは、本文中のHTMLタグなどが解釈される可能性があります。

一方、プレーンテキストとして扱うことで、ユーザー入力をHTMLとして解釈させず、シンプルな形式でメールを送信できます。

今回のお問い合わせフォームでは、HTMLメールにする必要がないため、プレーンテキストを採用しています。

FromとReply-Toを分ける理由

今回のメール送信では、

From
= AEDIの固定メールアドレス

Reply-To
= ユーザーが入力したメールアドレス

という役割分担にしています。

ユーザー入力をFromに直接使用するのではなく、Fromを固定したうえで、返信先としてユーザーのメールアドレスを利用する構成です。

ただし、Reply-Toもユーザー入力を含むメールヘッダーであるため、改行コードのチェックやメールアドレス形式の検証などを行っています。

このように、ユーザーから受け取った値をメールヘッダーに利用する場合は、「どのヘッダーに使うか」だけでなく、「その値が適切な形式になっているか」をサーバー側で確認することが重要です。

セキュリティヘッダー

今回のフォームでは、PHPからHTTPレスポンスヘッダーを設定し、ブラウザ側でもいくつかのセキュリティ対策を行っています。

設定しているヘッダーは以下のとおりです。

header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Frame-Options: SAMEORIGIN');
header("Content-Security-Policy: frame-ancestors 'self'");
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');

これらは、それぞれ異なる目的を持つセキュリティ機能です。

X-Content-Type-Options

X-Content-Type-Options: nosniffを設定することで、ブラウザがレスポンスのMIMEタイプを独自に推測することを抑制します。

今回のフォームでは、

header('X-Content-Type-Options: nosniff');

としています。

サーバーが指定したContent-Typeをブラウザが推測によって別のMIMEタイプとして解釈することを抑制し、意図しない形式でコンテンツが扱われるリスクを低減します。

Referrer-Policy

Referrer-Policyは、ユーザーが別のホームページへ移動するときなどに、ブラウザが送信するReferer情報をどの程度伝えるかを制御するためのヘッダーです。

今回のフォームでは、

header('Referrer-Policy: strict-origin-when-cross-origin');

としています。

strict-origin-when-cross-originでは、同一オリジンへのリクエストでは比較的詳細なReferer情報を送信し、異なるオリジンへのリクエストでは基本的にオリジン情報のみを送信します。

また、HTTPSからHTTPへのように、より安全性の低い通信へ移動する場合には、Referer情報を送信しません。

これにより、URLに含まれる情報などが必要以上に外部へ送信されることを抑制します。

X-Frame-Options

X-Frame-Optionsは、他のホームページからiframeなどを利用してページを埋め込まれることを制限するためのヘッダーです。

今回のフォームでは、

header('X-Frame-Options: SAMEORIGIN');

としており、同一オリジンからのフレーム表示を許可しています。

これにより、第三者のホームページからページをiframeなどで埋め込まれることを制限し、クリックジャッキング対策の一つとして機能します。

Content-Security-Policy

Content-Security-Policy(CSP)は、ブラウザがどのようなリソースを読み込んだり、どのような方法でページを利用したりできるかを制御するためのセキュリティ機能です。

CSPにはさまざまなディレクティブがありますが、今回のフォームでは、

header("Content-Security-Policy: frame-ancestors 'self'");

として、frame-ancestorsを設定しています。

frame-ancestors 'self'は、このページをiframeなどのフレーム内に表示できる親ページの範囲を同一オリジンに制限するものです。

これにより、第三者のホームページからページをフレーム内に埋め込まれることを防ぎ、クリックジャッキング対策として機能します。

X-Frame-OptionsとCSPの関係

X-Frame-OptionsとCSPのframe-ancestorsは、どちらもページのフレームへの埋め込みを制限するという点で役割が重なります。

今回のフォームでは、

X-Frame-Options: SAMEORIGIN

と、

Content-Security-Policy: frame-ancestors 'self'

の両方を設定しています。

同じ目的に関係する設定を複数組み合わせることで、異なるブラウザ環境なども考慮したクリックジャッキング対策の防御層としています。

ただし、CSPは本来、フレームの制御だけではなく、スクリプト、スタイル、画像、フォントなど、さまざまなリソースの読み込み元を制御できる非常に広い仕組みです。

今回はサイト全体の構成や外部リソースとの関係を考慮したうえで、必要なframe-ancestorsのみを設定しています。

Permissions-Policy

Permissions-Policyは、ブラウザが提供する一部の機能を、ホームページやそのフレームで利用できる範囲を制御するためのヘッダーです。

今回のフォームでは、

header('Permissions-Policy: camera=(), microphone=(), geolocation=()');

と設定しています。

これにより、今回のホームページでは、

  • カメラ
  • マイク
  • 位置情報

の機能を利用しないことを明示し、これらの機能を利用できないようにしています。

今回のホームページではこれらの機能を使用する必要がないため、不要なブラウザ機能へのアクセスを制限しています。

今回設定したセキュリティヘッダー

今回のフォームでは、それぞれ異なる役割を持つHTTPレスポンスヘッダーを組み合わせています。

X-Content-Type-Options

MIMEタイプの推測を抑制

Referrer-Policy

Referer情報の送信を制御

X-Frame-Options

iframeなどによる埋め込みを制限

Content-Security-Policy

今回はframe-ancestorsによるフレームへの埋め込みを制限

Permissions-Policy

不要なブラウザ機能の利用を制限

このように、セキュリティヘッダーについても、一つの設定だけに頼るのではなく、それぞれ異なる役割を持つ対策を組み合わせています。

二重送信への対策

お問い合わせフォームでは、ユーザーが送信ボタンを複数回クリックしたり、送信後にブラウザの戻る・再送信などの操作を行ったりすることで、同じお問い合わせが複数回送信される可能性があります。

今回のフォームでは、確認ページから送信処理へ進んだ段階で、セッションに保存していたお問い合わせデータを削除しています。

unset($_SESSION['contact_form']);

これは、send.phpで最終的なバリデーションが完了した後、メール送信処理を開始する直前に実行しています。

つまり、一度送信処理に入ったお問い合わせデータは、セッションから削除されます。

そのため、同じセッションに保存されていたお問い合わせデータを利用して、再度同じ内容を送信することを防ぎやすい構成にしています。

さらに、メール送信処理が完了した後には、CSRFトークンも破棄します。

unset($_SESSION['csrf_token']);

これにより、一度使用したCSRFトークンをその後の送信に再利用しにくくしています。

今回の処理の流れを簡単にすると、次のようになります。

confirm.php

セッションにお問い合わせデータを保存

send.php

最終バリデーション

$_SESSION['contact_form']を削除

管理者宛メールを送信

自動返信メールを送信

$_SESSION['csrf_token']を削除

thanks.htmlへリダイレクト

ただし、これはあらゆる状況で二重送信を完全に防止する仕組みではありません。

今回の実装では、送信済みのお問い合わせデータとCSRFトークンを破棄することで、同じ確認データを利用した再送信を防ぎやすくするという考え方で対策しています。

また、管理者宛メールの送信に失敗した場合は、send.phpでHTTPステータスコード500を返して処理を終了します。一方、管理者宛メールの送信が成功した後に自動返信メールの送信が失敗した場合は、お問い合わせ自体は成立したものとしてthanks.htmlへ進む実装になっています。

Honeypotによるスパム対策

お問い合わせフォームでは、セキュリティ対策だけでなく、迷惑な自動送信(スパム)への対策も重要です。

今回のフォームでは、Honeypot(ハニーポット)という方法を利用しています。

Honeypotとは?

Honeypotは、人間のユーザーは通常入力しないことを想定した入力欄をフォーム内に用意し、そこに値が入力されていた場合に、自動化されたスパム送信である可能性が高いと判断する方法です。

通常のユーザーにはその入力欄が見えないようにしておきます。

一方、単純なフォーム自動入力プログラムなどが、フォーム内にある入力欄を機械的に検出して値を入力した場合、そのHoneypot用の入力欄にも値が入る可能性があります。

その場合にサーバー側で送信を拒否します。

Honeypot用の入力欄を用意する

今回のフォームでは、以下のような入力欄を用意しています(Bootstrapを使用)。

<div class="mb-5 row d-none" aria-hidden="true">
    <div class="col-xl-3">
        <label for="website" class="col-form-label">
            ホームページ<span class="required">必須</span>:
        </label>
    </div>

    <div class="col-xl-3 col-md-6">
        <input
            type="text"
            name="website"
            id="website"
            tabindex="-1"
            autocomplete="off"
        >
    </div>
</div>

この入力欄は、通常のユーザーには表示されません。

また、tabindex="-1"を設定することで、キーボード操作による通常のフォーム入力の対象からも外しています。

autocomplete="off"も設定し、ブラウザによる自動入力の対象になりにくいようにしています。

サーバー側でHoneypotを確認する

Honeypotは、単に入力欄を隠すだけでは意味がありません。

サーバー側で、その入力欄に値が入っていないかを確認する必要があります。

今回のconfirm.phpでは、

$honeypot = trim((string)($_POST['website'] ?? ''));

if ($honeypot !== '') {
    http_response_code(403);
    exit('不正なリクエストです。');
}

として、Honeypot用の入力欄に値が入っていた場合は、不正なリクエストとして処理を中止します。

人間のユーザーが通常のフォームを利用した場合、この入力欄には値が入らないことを想定しています。

そのため、ここに値が入っていた場合には、自動化されたスパム送信などの可能性があると判断します。

なぜHoneypotを採用したのか

今回はreCAPTCHAなどの外部サービスを導入せず、まずはHoneypotによるスパム対策を行うことにしました。

理由は、ユーザーに画像認証やチェックボックスなどの追加操作を求める必要がなく、通常のユーザーは特別な操作をすることなくフォームを利用できるためです。

また、外部サービスに依存せず、フォーム側だけで比較的シンプルに実装できるというメリットもあります。

Honeypotにも限界がある

ただし、Honeypotだけですべてのスパム送信を防げるわけではありません。

スパム送信プログラムがHoneypotの存在を認識したり、JavaScriptやCSSなどを考慮して人間の操作を模倣したりするようになれば、この方法を回避される可能性があります。

そのため、Honeypotは「これだけでスパムを完全に防ぐ仕組み」ではなく、今回のフォームにおけるスパム対策の一つとして導入しています。

今後スパム送信が増える場合には、送信頻度の制限やレート制限、Cloudflare Turnstile、reCAPTCHAなど、別の対策を追加することを検討します。

今回のHoneypot対策

今回のフォームでは、

Honeypot用の入力欄を用意

通常のユーザーには表示しない

フォーム送信

サーバー側でHoneypotの値を確認

値が入っていればリクエストを拒否

という仕組みにしています。

ユーザーの操作を増やすことなく、比較的シンプルな方法で自動化されたスパム送信を検出するための対策です。

「完璧なセキュリティ」は存在しない

ここまでさまざまな対策を行っても、このフォームが100%安全だとは言えません。

セキュリティは、一つの対策だけですべてのリスクを防ぐものではないからです。

今回のフォームでは、

入力

バリデーション

セッション

CSRF

出力

メール

HTTPヘッダー

スパム対策

というように、フォームの入力からメール送信まで、それぞれの段階で異なるリスクを考え、対策を行っています。

重要なのは、「完全に安全なものを作った」と考えることではありません。

その時点で想定できるリスクを一つずつ確認し、可能な範囲でリスクを減らしていくことが大切だと考えています。

Defense in Depth(多層防御)

今回のフォームでは、CSRF、セッションCookie、サーバー側バリデーション、XSS対策、メールヘッダーインジェクション対策、セキュリティヘッダー、Honeypotなど、複数の対策を組み合わせています。

これは、いわゆる「Defense in Depth(多層防御)」という考え方にもつながります。

Defense in Depthとは、一つの対策だけに依存するのではなく、複数の異なる防御策を組み合わせることで、全体としてリスクを低減していく考え方です。

例えば、入力値の検証だけでなく、出力時のエスケープ、CSRF対策、セッションCookieの保護、メールヘッダーの検証などを組み合わせることで、一つの対策だけに依存しない構成にしています。

もちろん、複数の対策を実装したからといって、攻撃を完全に防げるわけではありません。

しかし、一つの対策だけに頼るのではなく、異なる場所に複数の防御策を設けることで、それぞれのリスクを個別に低減し、全体としてより安全な構成を目指すことができます。

セキュリティ対策は継続する

ホームページは、制作して公開したら終わりではありません。

PHPやWebサーバー、メール環境、ブラウザなどのソフトウェアやサービスは変化します。

新しい脆弱性が発見されることもありますし、新しい攻撃手法が登場することもあります。

そのため、公開後も必要に応じて設定や実装を見直し、改善していくことが重要です。

今回のフォームについても、今回実装した対策ですべて終わりというわけではありません。

今後、新しいリスクやより適切な対策が分かった場合には、必要に応じて改善していきたいと考えています。

そのため、ホームページのセキュリティは、

制作

公開

運用

確認

改善

というサイクルを継続していくことが大切だと考えています。

セキュリティチェックリスト

ここまで説明してきた今回のお問い合わせフォームのセキュリティ対策を、チェックリストとしてまとめます。

CSRF

  • CSRFトークンを生成
  • CSRFトークンをセッションに保存
  • POSTされたCSRFトークンを検証
  • hash_equals()を使用
  • 最終送信処理でもCSRFトークンを検証
  • 送信後にCSRFトークンを破棄

セッション

  • session.use_only_cookiesを有効化
  • session.use_strict_modeを有効化
  • HTTPS時にSecure Cookieを使用
  • HttpOnlyを設定
  • SameSite=Laxを設定
  • セッションIDを再生成

XSS

  • ユーザー入力を信用しない
  • サーバー側で入力値を検証
  • HTML出力時にhtmlspecialchars()を使用
  • ユーザー入力をそのままHTMLとして出力しない

バリデーション

  • 問い合わせ種類をAllowlistで検証
  • 必須項目をサーバー側でも確認
  • 各項目に文字数制限を設定
  • 郵便番号の形式を確認
  • 電話番号の形式を確認
  • Emailアドレスの形式を確認
  • Emailアドレスの最大文字数を制限
  • 個人情報保護方針への同意を確認
  • confirm.phpで検証
  • send.phpでも最終検証

メール

  • Fromを自社ドメインのメールアドレスに固定
  • ユーザーのメールアドレスをReply-Toに使用
  • メールアドレスの改行コードを拒否
  • メール本文をプレーンテキストで送信
  • ユーザー入力をFromに使用しない

HTTPセキュリティヘッダー

  • HSTS(Strict-Transport-Security)
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-Frame-Options: SAMEORIGIN
  • Content-Security-Policy: frame-ancestors ‘self’
  • Permissions-Policy

スパム対策

  • Honeypot用の入力欄を設置
  • Honeypotの値をサーバー側で確認
  • Honeypotに値が入っていた場合は処理を拒否

フォーム送信・運用

  • 送信済みのセッションデータを破棄
  • エラー時に内部情報を表示しない
  • 管理者宛メール送信失敗時にHTTP 500を返す

まとめ

今回、静的サイトのお問い合わせフォームを制作するにあたり、単に「入力された内容をメールで送る」だけではなく、その途中にあるさまざまなリスクについて考えながら設計・実装しました。

最終的には、

contact.html

CSRF

confirm.php

入力値の検証
セッション管理
Honeypot

send.php

最終バリデーション

メールヘッダー対策

メール送信

thanks.html

という構成になりました。

CSRF対策、セッション管理、XSS対策、サーバー側バリデーション、メールヘッダーインジェクション対策、セキュリティヘッダー、Honeypotなど、それぞれ異なるリスクに対して複数の対策を組み合わせています。

静的サイトは、HTML、CSS、JavaScriptを中心に比較的シンプルに構築できる一方、お問い合わせフォームのようなサーバー側の機能を追加する場合には、自分たちで考えなければならないことが増えます。

WordPressのようにフォームプラグインを利用できる環境とは異なり、フォームの入力値をどのように受け取り、どこで検証し、どのようにセッションで管理し、最終的にどのようにメールを送信するのかまで、自分たちで設計する必要があります。

今回の制作を通して、私自身も改めて、

「フォームは単に動けばいいわけではない」

ということを実感しました。

デザインやHTML、CSS、JavaScriptだけでなく、サーバー側で何が起こっているのか。

入力されたデータがどこを通って、どのように検証され、どのように保存され、最終的にどのような形でメールとして送信されるのか。

そこまで考えてホームページを制作することが大切だと感じています。

もちろん、今回実装した対策だけで、このフォームが完全に安全になるわけではありません。

ホームページのセキュリティは、制作して公開したら終わりではなく、運用しながら状況を確認し、新しい情報や脆弱性、攻撃手法などに応じて必要な対策を追加・改善していくことが重要です。

今回のフォームについても、今後の運用状況を確認しながら、必要に応じて改善していきたいと思います。

今回の記事が、静的サイトにお問い合わせフォームを実装する際に、「フォームを動かす」だけではなく、「安全に動かすにはどうすればよいか」を考えるきっかけになれば幸いです。

AEDI オフィス

AEDI ロゴ

AEDI(エーイーディーアイ)株式会社は、岡山県倉敷市を拠点とするホームページ制作会社・デザイン会社です。ホームページ制作、グラフィックデザイン、モーションデザイン、キャラクターデザイン、ブランドデザインなどのサービスを提供し、Webとデザインを通して、お客様とそのサービス・商品の魅力を引き出し、新しい価値を提案いたします。

Back to Top
目次(Table of Contents)