【連載第11回】Webアプリをハッカーから守れ!XSS・SQLインジェクション・CSRF対策の総仕上げ

LAMP講座

【連載第11回】「動けばいい」は素人。「壊されない」のがプロの仕事。

未経験からWebアプリ開発を目指す「AlmaLinux 9とLAMP環境で作る!Webアプリ開発完全ロードマップ(全12回)」の第11回です。

前回までで、掲示板アプリとしての機能(投稿、表示、編集、削除)はすべて完成しました。
画面も綺麗ですし、動きもスムーズです。

しかし、残念ながら今のままでは、このアプリを世界に公開することはできません。
なぜなら、今のこのアプリは「鍵のかかっていない金庫」のようなものだからです。

今回は、Webアプリケーションを狙う悪質なサイバー攻撃から、あなたとユーザーを守るための「セキュリティ対策」を総点検します。
特に、まだ実装していなかった重要防御策「CSRF対策」をコードに組み込み、アプリを完成形へと進化させます。

コウ君

先生、セキュリティってウイルス対策ソフトを入れておけばいいんじゃないんですか?
プログラムで防ぐってどういうことでしょう?
せっかく動いてるのに、いじって壊したくないなぁ…。

リナックス先生

甘いわねコウ君。
サーバーのウイルス対策も大事だけど、「プログラムの書き方のミス(脆弱性)」を突かれたら、どんな高価なセキュリティソフトも無力なのよ。
今日は「ハッカーがどうやって攻撃してくるのか」を知ることで、防御の意味を深く理解しましょう。

本講座のカリキュラム(全12回)

現在地は第11回です。開発パートの最終回です!

  1. サーバー準備編:なぜVPSが必要?AlmaLinux 9の初期設定とSSH接続
  2. Webサーバー編:Apache(httpd)のインストールとファイアウォール設定
  3. データベース編:MariaDB(MySQL)のインストールとセキュリティ設定
  4. プログラミング言語編:PHP 8.xの導入と設定ファイルのチューニング
  5. 権限・パーミッション編:LinuxでWebサイトを公開するための「所有者」の概念
  6. 接続テスト編:PHPからデータベース(DB)に接続してみよう
  7. アプリ開発①:HTML/CSSで掲示板の「見た目」を作る
  8. アプリ開発②:投稿機能(Create)の実装とデータの保存
  9. アプリ開発③:一覧表示機能(Read)と画像表示の仕組み
  10. アプリ開発④:編集・削除機能(Update/Delete)の実装
  11. 【今回】セキュリティ編:XSSやSQLインジェクション対策の基礎
  12. 公開編:独自ドメイン設定と無料SSL(Let’s Encrypt)でHTTPS化

Webアプリの3大脆弱性とは?

Webアプリを開発する上で、絶対に避けて通れない「3つの巨大な敵」がいます。

  1. SQLインジェクション: データベースを破壊・盗難する攻撃。
  2. XSS(クロスサイト・スクリプティング): ユーザーのブラウザ上で悪意あるプログラムを動かす攻撃。
  3. CSRF(クロスサイト・リクエスト・フォージェリ): ユーザーになりすまして勝手に操作させる攻撃。

これまでの講座で、1と2については部分的に対策をしてきました。
今回はそれらの復習(深掘り)と、まだ対策していない3つ目の「CSRF」への対処を行います。

敵その1:SQLインジェクション(復習)

どんな攻撃か?

投稿フォームの名前に ' OR '1'='1 と入力して送信することで、SQL文の意味を改変してしまう攻撃です。
これにより、本来見えないはずの他人のデータを見たり、データベース全体を削除したりすることが可能になります。

どう防いだか?

第8回の実装で、「プリペアドステートメント(静的プレースホルダー)」を使いましたね。

// 安全な書き方
$sql = "INSERT INTO posts (name) VALUES (:name)";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':name', $name, PDO::PARAM_STR);

:name という「空箱」を用意しておき、後から値を安全な形で詰め込むことで、入力された文字がSQLコマンドとして実行されるのを防ぎました。
「変数は直接SQLに埋め込まない」。これが絶対のルールです。

敵その2:XSS(クロスサイト・スクリプティング)(復習)

どんな攻撃か?

投稿本文に <script>...</script> というJavaScriptのコードを書き込み、それを閲覧した他人のブラウザで実行させる攻撃です。
偽のログイン画面を出してパスワードを盗んだり、勝手にウイルスサイトへ転送したりします。

どう防いだか?

第9回の実装で、「htmlspecialchars関数」を使いました。

<?php echo htmlspecialchars($post['comment'], ENT_QUOTES, 'UTF-8'); ?>

これにより、<> といった記号がただの「文字」に変換され、プログラムとして動かなくなります。
「ユーザーが出したデータは全て汚染されている」と考え、表示する直前に必ず消毒(サニタイズ)するのが鉄則です。

敵その3:CSRF(クロスサイト・リクエスト・フォージェリ)

さて、ここからが本題です。まだ対策していない、非常に危険な脆弱性です。

どんな攻撃か?

直訳すると「サイト横断的なリクエスト偽造」です。
例えば、あなたが掲示板にログインした状態で、攻撃者が作った「罠サイト」にアクセスしてしまったとします。

その罠サイトには、目に見えない形で以下のようなフォームが仕込まれていました。

<!-- 罠サイトの裏側 -->
<form action="http://あなたの掲示板/delete.php" method="POST">
    <input type="hidden" name="id" value="1">
</form>
<script>document.forms[0].submit();</script>

罠サイトを開いた瞬間、勝手に「削除ボタン」が押されたことになり、あなたの掲示板の投稿が削除されてしまいます。
もっと怖い例だと、銀行サイトで勝手に送金されたり、ショッピングサイトで勝手に買い物をされたりします。
「本人が操作していないのに、本人のブラウザから勝手にリクエストが送られる」のがCSRFの恐怖です。

どう防ぐか?(ワンタイムトークン)

これを防ぐには、「このリクエストは、間違いなく私のサイトにあるフォームから送信されたものだ」という証明が必要です。

そのために「ワンタイムトークン(合言葉)」を使います。

  1. フォームを表示する時、サーバー側でランダムな「合言葉(トークン)」を発行する。
  2. その合言葉を、ユーザーには見えない形(hidden)でフォームに埋め込む。
  3. 同時に、サーバー側(セッション)にも合言葉を覚えておく。
  4. 送信された時、フォームの合言葉とサーバーの合言葉が一致するか確認する。

攻撃者の罠サイトは、この「その場限りの合言葉」を知ることができないため、リクエストを送っても「合言葉が違う!」とサーバーに拒否されます。

実践:CSRF対策を実装しよう

それでは、index.php にCSRF対策(ワンタイムトークン)を組み込んでいきましょう。
PHPの「セッション機能」を使います。

vi /var/www/html/bbs/index.php

修正ポイント1:セッションの開始とトークン生成

ファイルの先頭部分を以下のように修正します。

<?php
// セッション開始(これがないとデータを記憶できない)
session_start();

// エラー表示(本番環境では削除)
ini_set('display_errors', 1);
error_reporting(E_ALL);

// DB接続情報
$dsn = 'mysql:dbname=app_db;host=localhost;charset=utf8mb4';
$user = 'app_user';
$password = 'password123';

// CSRFトークンの生成(なければ作る)
if (empty($_SESSION['csrf_token'])) {
    // 32バイトのランダムな文字列を生成
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

修正ポイント2:送信時のトークンチェック

POST送信された直後に、トークンが正しいかチェックする処理を入れます。

// ▼ 1. 投稿機能(POST時の処理)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // CSRFトークンの検証
    if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
        die('不正なリクエストです。(CSRF検出)');
    }

    // ...以下、いつもの投稿処理...

修正ポイント3:フォームへのトークン埋め込み

HTMLのフォーム(投稿フォームと削除フォームの両方)に、トークンを送信するための隠し項目を追加します。

投稿フォーム:

<form action="index.php" method="post" enctype="multipart/form-data">
    <!-- ここに追加! -->
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
    
    <div class="input-group">
    ...

削除フォーム(index.php内のループ部分):

    <form action="delete.php" method="post" style="display: inline;" onsubmit="return confirm('本当に削除しますか?');">
        <input type="hidden" name="id" value="<?php echo $post['id']; ?>">
        <!-- ここにも追加! -->
        <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
        <button type="submit" ...>削除</button>
    </form>

完全版ソースコード(index.php)

修正箇所が多いので、CSRF対策済みの完全版コードを掲載します。
これを丸ごとコピーして上書きすれば確実です。

<?php
session_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);

$dsn = 'mysql:dbname=app_db;host=localhost;charset=utf8mb4';
$user = 'app_user';
$password = 'password123';

// CSRFトークン生成
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

$success_message = null;
$error_message = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // CSRFチェック
    if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
        die('不正なリクエストです。');
    }

    $name = $_POST['name'] ?? '';
    $comment = $_POST['comment'] ?? '';
    $image_filename = null;

    if (empty($name) || empty($comment)) {
        $error_message = '名前とコメントは必須です。';
    } else {
        if (!empty($_FILES['image']['name']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
            $original_name = $_FILES['image']['name'];
            $extension = pathinfo($original_name, PATHINFO_EXTENSION);
            // ファイル名も安全のためにbasenameを通す
            $safe_filename = basename($original_name);
            $new_filename = date('YmdHis') . '_' . uniqid() . '.' . $extension;
            $upload_dir = '/var/www/html/bbs/images/';
            
            if (move_uploaded_file($_FILES['image']['tmp_name'], $upload_dir . $new_filename)) {
                $image_filename = $new_filename;
            } else {
                $error_message = '画像のアップロードに失敗しました。';
            }
        }

        if (empty($error_message)) {
            try {
                $pdo = new PDO($dsn, $user, $password);
                $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
                $sql = "INSERT INTO posts (name, comment, image_path) VALUES (:name, :comment, :image)";
                $stmt = $pdo->prepare($sql);
                $stmt->bindValue(':name', $name, PDO::PARAM_STR);
                $stmt->bindValue(':comment', $comment, PDO::PARAM_STR);
                $stmt->bindValue(':image', $image_filename, $image_filename ? PDO::PARAM_STR : PDO::PARAM_NULL);
                $stmt->execute();
                $success_message = '投稿しました!';
            } catch (PDOException $e) {
                $error_message = 'DBエラー: ' . $e->getMessage();
            }
        }
    }
}

$posts = [];
try {
    $pdo = new PDO($dsn, $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $sql = "SELECT * FROM posts ORDER BY created_at DESC";
    $stmt = $pdo->query($sql);
    $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
    $error_message = 'データ取得エラー: ' . $e->getMessage();
}
?>

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ひとこと掲示板</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>ひとこと掲示板</h1>
    </header>

    <main>
        <?php if ($success_message): ?>
            <div class="alert success"><?php echo htmlspecialchars($success_message, ENT_QUOTES, 'UTF-8'); ?></div>
        <?php endif; ?>
        <?php if ($error_message): ?>
            <div class="alert error"><?php echo htmlspecialchars($error_message, ENT_QUOTES, 'UTF-8'); ?></div>
        <?php endif; ?>

        <div class="form-container">
            <form action="index.php" method="post" enctype="multipart/form-data">
                <!-- CSRFトークン -->
                <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
                
                <div class="input-group">
                    <label>ニックネーム</label>
                    <input type="text" name="name" required placeholder="例:名無しさん">
                </div>
                <div class="input-group">
                    <label>コメント</label>
                    <textarea name="comment" required placeholder="今の気持ちをつぶやこう"></textarea>
                </div>
                <div class="input-group">
                    <label>画像(任意)</label>
                    <input type="file" name="image" accept="image/*">
                </div>
                <button type="submit" name="submit" class="submit-btn">投稿する</button>
            </form>
        </div>

        <hr>

        <div class="timeline">
            <?php foreach ($posts as $post): ?>
                <article class="post">
                    <div class="post-header">
                        <span class="post-name">
                            <?php echo htmlspecialchars($post['name'], ENT_QUOTES, 'UTF-8'); ?>
                        </span>
                        <span class="post-date">
                            <?php echo date('Y/m/d H:i', strtotime($post['created_at'])); ?>
                        </span>
                    </div>
                    <div class="post-content">
                        <p>
                            <?php echo nl2br(htmlspecialchars($post['comment'], ENT_QUOTES, 'UTF-8')); ?>
                        </p>
                        <?php if (!empty($post['image_path'])): ?>
                            <img src="./images/<?php echo htmlspecialchars($post['image_path'], ENT_QUOTES, 'UTF-8'); ?>" alt="投稿画像" class="post-image">
                        <?php endif; ?>
                    </div>
                    <div class="post-actions" style="margin-top: 10px; text-align: right;">
                        <a href="edit.php?id=<?php echo $post['id']; ?>" style="color: blue; margin-right: 10px; text-decoration: none;">編集</a>
                        <form action="delete.php" method="post" style="display: inline;" onsubmit="return confirm('本当に削除しますか?');">
                            <input type="hidden" name="id" value="<?php echo $post['id']; ?>">
                            <!-- CSRFトークン -->
                            <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
                            <button type="submit" style="color: red; border: none; background: none; cursor: pointer; text-decoration: underline;">削除</button>
                        </form>
                    </div>
                </article>
            <?php endforeach; ?>
        </div>
    </main>
    <footer>
        <p>&copy; 2026 Linux Koubou BBS Project</p>
    </footer>
</body>
</html>

宿題:
delete.phpedit.php にも、同じように session_start() とトークンチェックの処理を追加してみてください。
これができれば、あなたはもうセキュリティの基本をマスターしています。

次回予告:独自ドメインとSSLで「本物のサイト」へ

これでアプリの中身は完璧に完成しました!
しかし、URLを見てください。http://123.456.78.90/bbs/ のように、無機質なIPアドレスのままではありませんか?
しかも、ブラウザのアドレスバーに「保護されていない通信」と警告が出ていませんか?

最終回となる次回は、このアプリに「あなただけのドメイン(.comなど)」を割り当て、さらに「無料SSL(HTTPS)」を設定して、鍵マーク付きの安全なサイトとして世界に公開します。
感動のフィナーレをお楽しみに!

リナックス先生

セキュリティ対策は「面倒くさい」と感じるかもしれないけど、ユーザーの信頼を守るためには絶対に必要なコストなの。
ここまで頑張った自分を褒めてあげてね。
次回のドメイン設定で、いよいよ「プロのエンジニア」の仲間入りよ!

▼この講座で使用している推奨VPS

【2026年最新】Linuxサーバー構築におすすめのVPS比較3選!現役エンジニアが速度とコスパで厳選
Linuxの勉強、まだ「自分のPC」でやって消耗していませんか?「Linuxを覚えたいけど、環境構築でエラーが出て先に進めない…」「VirtualBoxを入れたらパソコンが重くなった…」これは、Linux学習を始める9割の人がぶつかる壁です...

コメント