フォームの確認画面から戻るボタンを実装する際には、button の formaction 属性を使ってみよう

お問い合わせフォームなどで「入力画面 → 確認画面 → 完了画面」という流れを実装するとき、確認画面の「戻る」ボタンの実装に悩んだ経験はないでしょうか。

「戻る」ボタンの実装にはいくつかのアプローチがあります。それぞれの方法と課題を整理した上で、button 要素の formaction 属性を使った実装方法を、CSRF 対策・XSS 対策を含めて解説します。


よく使われる「戻る」ボタンの実装方法と課題

JavaScript を使う方法

最もシンプルなのは history.back() を使う方法です。

document.getElementById('back-btn').addEventListener('click', function() {
    history.back();
});

ブラウザの「戻る」ボタンと同じ動作をします。ただしブラウザのキャッシュに依存するため、環境によっては入力内容が消えてしまうことがあります。

より確実な方法として sessionStorage に入力値を保存しておき、入力画面に戻ったときに復元するアプローチもあります。ただし保存・復元のための JavaScript コードが別途必要になり、実装が複雑になります。

セッションを使う方法

PHP のセッションに入力値を保存しておく方法です。

// confirm.php:入力値をセッションに保存
session_start();
$_SESSION['form_data'] = $_POST;

// input.php:セッションから入力値を復元
session_start();
$formData = $_SESSION['form_data'] ?? [];

「戻る」ボタンは header('Location: input.php') でリダイレクトするだけで済むため、入力値の消失は起きません。ただしセッションの開始・破棄の管理が必要になり、コードが増えます。

各方法の比較

history.back():コード量は少ないが、ブラウザ依存で不安定
sessionStorage:中程度のコード量だが、JavaScript が無効だと動かない
セッション(PHP):安定しているが、セッション管理のコードが必要
formaction:コード量が少なく、安定している

formaction を使う方法は、余分なコードを書かずに済み、JavaScript が無効な環境でも動作するのが利点です。


formaction 属性とは

formaction 属性は、button 要素や input[type=”submit”] に指定できる属性です。フォームの action 属性を上書きして、そのボタンが押されたときだけ別の URL にデータを送信できます。

通常、フォームは action 属性に指定した URL にデータを送信します。しかし formaction 属性を持つボタンを使うと、そのボタンが押されたときだけ別の URL に送信先を切り替えられます。

確認画面に「戻る」ボタンと「送信する」ボタンの2つを置き、それぞれ別の URL に送信するという使い方がまさにこのケースにあたります。


実装する画面の流れ

この記事では以下の3画面を実装します。

フォームの項目はシンプルに「お名前」「メールアドレス」「お問い合わせ内容」の3つとします。

この記事のコードには、以下の2つのセキュリティ対策を最初から組み込みます。

・XSS(クロスサイトスクリプティング)対策:htmlspecialchars() での出力エスケープ
・CSRF(クロスサイトリクエストフォージェリ)対策:セッションを使ったトークンの検証


CSRF 対策の準備:トークンを発行・検証する仕組み

CSRF 対策の実装から先に説明します。3画面すべてでこの仕組みを使うため、最初に理解しておくと後のコードが読みやすくなります。

CSRF トークンとは

CSRFトークンは、正規のフォームを経由したリクエストであることを確認するための使い捨ての文字列です。セッションに保存したトークンと、フォームから送信されたトークンが一致するかを確認することで、悪意のある第三者が別のサイトから勝手にフォームを送信する攻撃(CSRF)を防ぎます。

トークンを発行する関数

全画面で使う共通処理として、functions.php のようなファイルにまとめておきます。

<?php

session_start();

// セッションにトークンがなければ新しく発行する
function generateCsrfToken(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

// 送信されたトークンとセッションのトークンを比較する
function verifyCsrfToken(?string $token): bool
{
    if (empty($_SESSION['csrf_token']) || empty($token)) {
        return false;
    }
    // hash_equals でタイミング攻撃を防ぎながら比較する
    return hash_equals($_SESSION['csrf_token'], $token);
}

// 使用済みのトークンを破棄する
function clearCsrfToken(): void
{
    unset($_SESSION['csrf_token']);
}

hash_equals() を使っているのは、文字列比較にかかる時間の差から情報が漏れる「タイミング攻撃」を防ぐためです。=== で比較する実装もよく見かけますが、CSRFトークンの検証では hash_equals() を使うのが安全です。

各画面の先頭で require 'functions.php'; として読み込む前提で、以降のコードを解説します。


入力画面(input.php)の実装

入力画面は通常のフォームです。確認画面から「戻る」ボタンで戻ってきたときに入力内容が消えないよう、POST データがあれば各フィールドに値をセットします。またフォームには CSRF トークンを hidden フィールドとして埋め込みます。

<?php
require 'functions.php';
 
$csrfToken = generateCsrfToken();
?>
<form action="confirm.php" method="post">
 
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
 
    <div>
        <label>お名前</label>
        <input type="text" name="name"
            value="<?= isset($_POST['name']) ? htmlspecialchars($_POST['name'], ENT_QUOTES, 'UTF-8') : '' ?>">
    </div>
    <div>
        <label>メールアドレス</label>
        <input type="email" name="email"
            value="<?= isset($_POST['email']) ? htmlspecialchars($_POST['email'], ENT_QUOTES, 'UTF-8') : '' ?>">
    </div>
    <div>
        <label>お問い合わせ内容</label>
        <textarea name="message"><?= isset($_POST['message']) ? htmlspecialchars($_POST['message'], ENT_QUOTES, 'UTF-8') : '' ?></textarea>
    </div>
    <button type="submit">確認する</button>
</form>

ポイントは value に isset($_POST[‘name’]) の確認を入れている点です。初回表示(POST データなし)でも、戻ってきたとき(POST データあり)でも、どちらでも正しく動くようになっています。

また、htmlspecialchars() を使って XSS 対策を施しています。ユーザーの入力値をそのまま HTML に出力するのは危険なため、必ず行うようにしましょう。

CSRF トークンは generateCsrfToken() で取得しています。すでにセッションにトークンがある場合(戻ってきた場合)はそれを再利用し、なければ新しく発行します。


確認画面(confirm.php)の実装

確認画面では、まず CSRF トークンを検証してから処理を進めます。検証をパスしたら、hidden フィールドで入力値を保持しつつ、formaction 属性を使った「戻る」ボタンを実装します。

<?php
require 'functions.php';
 
// CSRFトークンを検証する
if (!verifyCsrfToken($_POST['csrf_token'] ?? null)) {
    http_response_code(403);
    exit('不正なリクエストです。お手数ですが、最初からやり直してください。');
}
 
// 確認画面から先の画面(戻る・完了)でも同じトークンを使い続ける
$csrfToken = generateCsrfToken();
?>
<form action="complete.php" method="post">
 
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
 
    <!-- 入力値を hidden フィールドで保持する -->
    <input type="hidden" name="name"
        value="<?= htmlspecialchars($_POST['name'], ENT_QUOTES, 'UTF-8') ?>">
    <input type="hidden" name="email"
        value="<?= htmlspecialchars($_POST['email'], ENT_QUOTES, 'UTF-8') ?>">
    <input type="hidden" name="message"
        value="<?= htmlspecialchars($_POST['message'], ENT_QUOTES, 'UTF-8') ?>">
 
    <!-- 確認内容を表示する -->
    <dl>
        <dt>お名前</dt>
        <dd><?= htmlspecialchars($_POST['name'], ENT_QUOTES, 'UTF-8') ?></dd>
 
        <dt>メールアドレス</dt>
        <dd><?= htmlspecialchars($_POST['email'], ENT_QUOTES, 'UTF-8') ?></dd>
 
        <dt>お問い合わせ内容</dt>
        <dd><?= nl2br(htmlspecialchars($_POST['message'], ENT_QUOTES, 'UTF-8')) ?></dd>
    </dl>
 
    <!-- 戻るボタン:formaction で input.php に送信先を上書きする -->
    <button type="submit" formaction="input.php">戻る</button>
 
    <!-- 送信ボタン:form の action である complete.php に送信する -->
    <button type="submit">送信する</button>
 
</form>

コードのポイント①:確認画面の先頭で CSRF トークンを検証する

verifyCsrfToken() が false を返した場合、つまりトークンが一致しない、またはそもそも送信されていない場合は、処理を中断します。これにより、他サイトに設置された偽フォームから confirm.php に直接 POST を送りつける攻撃を防ぎます。

コードのポイント②:hidden フィールドで入力値と CSRF トークンを保持する

確認画面では入力値と CSRF トークンを hidden フィールドとして保持しています。「戻る」「送信する」のどちらのボタンを押しても、この hidden フィールドの値が一緒に送信されます。

コードのポイント③:formaction 属性で戻り先を指定する

「戻る」ボタンに formaction=”input.php” を指定しています。このボタンが押されると、フォームの action 属性(complete.php)が無視され、代わりに input.php に POST データが送信されます。

<button type="submit" formaction="input.php">戻る</button>

input.php 側では、戻ってきた POST データの csrf_token をそのまま hidden フィールドに再セットするため、input.php で改めて CSRF トークンを検証する必要はありません(input.php はフォームを表示するだけで、データの更新や保存を行わないためです)。

コードのポイント④:nl2br() でお問い合わせ内容の改行を表示する

textarea に入力された内容には改行コードが含まれますが、HTML ではそのままでは改行として表示されません。nl2br() を使うことで、改行コードを
タグに変換できます。


完了画面(complete.php)の実装

完了画面でも CSRF トークンを検証します。検証をパスしたら、データベースへの保存やメール送信などの処理を行い、最後に使用済みのトークンを破棄します。

<?php
require 'functions.php';
 
// CSRFトークンを検証する
if (!verifyCsrfToken($_POST['csrf_token'] ?? null)) {
    http_response_code(403);
    exit('不正なリクエストです。お手数ですが、最初からやり直してください。');
}
 
$name    = $_POST['name']    ?? '';
$email   = $_POST['email']   ?? '';
$message = $_POST['message'] ?? '';
 
// ここでデータベースへの保存やメール送信などの処理を行う
// save($name, $email, $message);
 
// 送信が完了したのでトークンを破棄する(再送信防止にもなる)
clearCsrfToken();
?>
<p>お問い合わせありがとうございました。</p>
<dl>
    <dt>お名前</dt>
    <dd><?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></dd>
 
    <dt>メールアドレス</dt>
    <dd><?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?></dd>
</dl>

clearCsrfToken() で使用済みのトークンを破棄しています。これにより、ブラウザの「戻る」ボタンなどで complete.php への送信をブラウザが再送しようとしても、トークンが一致せずエラーになります。フォームの二重送信防止としても機能します。


よくある実装ミス

input.php で CSRF トークンを検証してしまう

input.php はデータの更新や保存を一切行わない、フォームを「表示するだけ」の画面です。ここで厳密な CSRF 検証を行うと、確認画面から正しく戻ってきたユーザーまで弾いてしまう可能性があります。CSRF対策が本当に必要なのは、データを実際に処理する confirm.php と complete.php です。

トークンの比較に === を使ってしまう

// NG:タイミング攻撃のリスクがある
if ($_SESSION['csrf_token'] === $token) { ... }

// OK:hash_equals() を使う
if (hash_equals($_SESSION['csrf_token'], $token)) { ... }

文字列の比較処理にかかる時間の差から情報が漏れる可能性があるため、トークンの比較には必ず hash_equals() を使いましょう。

完了後にトークンを破棄し忘れる

完了画面でトークンを破棄しないと、同じトークンで complete.php に何度もリクエストを送れてしまい、意図しない二重送信につながります。処理完了後は必ず clearCsrfToken() を呼びましょう。


まとめ

button 要素の formaction 属性を使うと、確認画面の「戻る」ボタンをシンプルに実装できます。

実装のポイントをまとめます。

  • 確認画面のフォームの action は完了画面の URL を指定する
  • 入力値は hidden フィールドで保持する
  • 「戻る」ボタンに formaction で入力画面の URL を指定する
  • 入力画面では POST データがあれば各フィールドに値をセットする
  • 入力値の出力には必ず htmlspecialchars() を使う(XSS対策)
  • データを処理する画面(確認画面・完了画面)では必ず CSRF トークンを検証する
  • トークンの比較には hash_equals() を使う
  • 処理完了後はトークンを破棄する

formaction は JavaScript やセッションを使わずにシンプルに実装できる一方、フォームである以上 CSRF・XSS への対策は他の実装方法と同様に欠かせません。この記事のコードをベースに、ぜひ実務でも試してみてください。

コメント

タイトルとURLをコピーしました