【PHP初心者向け】セキュアな掲示板を最小構成から作る

  • 95
    いいね
  • 3
    コメント

Level 0: HTMLのみを記述したコード

<html> <head> <body> を省略する,HTML5の最小構成で書きます.

<!DOCTYPE html>
<meta charset="UTF-8">
<title>掲示板</title>
<h1>掲示板</h1>
<section>
    <h2>新規投稿</h2>
    <form action="" method="post">
        名前: <input type="text" name="name" value=""><br>
        本文: <input type="text" name="text" value=""><br>
        <button type="submit">投稿</button>
    </form>
</section>
<section>
    <h2>投稿一覧</h2>
    <p>投稿はまだありません</p>
</section>

Level 1: CSV形式で書き込む最小限の実装

この記事では,CSV形式をメインに説明します.この方法はCSV形式以外にも,行単位で意味を持つあらゆる形式に応用出来ます.

CSV形式 (1列目名前,2列目本文 特殊記号がある場合は"で括らなければならない)
山田,こんにちは
山本,"Hello, World"
田中,タイキック

ファイルに書き込むために最低限必要な流れは以下のようになります.

  1. ファイル名とファイルオープンモードを指定して,fopen関数でファイルを操作するための識別子(リソース)を取得する.
  2. 文字列をそのまま書き込むためにはfwrite関数,配列をCSV形式に変換して1行として書き込むためにはfputcsv関数を利用する.
  3. fclose関数でファイルを閉じる.(これは省略可能であるが,今回は丁寧に記述する)

ファイルオープンモードについて,「ファイルオープンモードに関するマニュアルの記述は間違っている」で触れていることについても最小限の説明を行っておきます.下図で登場する「ポインタ」は,テキストエディタで言えばいわゆる「点滅する縦棒」に相当するものです.編集中の位置を表しています.また,はそのモードでは実行できない操作であることを示しています.

f62d508d-32d6-8de5-3cac-f0d8d3f2c202.png

今回要求しているのは

  • 書き込み自体がまず出来ること
  • 新しい書き込みがファイルの終端に次々に書き足されていくこと

なので,採用すべきモードはaです.このモードは書き込むときに自動的にポインタを終端に移動してくれます.ファイルが存在しない時に新規作成してくれるのも地味に便利です.ただし,Windows環境では改行コードが\nではなく\r\nにされてしまうので,全て\nに統一するためにbオプションも付けてabにしておきます.

これまでに挙げたことを踏まえてコードを書いてみます.

<?php

// POSTとして送信されてきたときのみ実行
// (通常アクセスはGET,フォーム送信はPOST)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $fp = fopen('data.csv', 'ab');
    fputcsv($fp, [$_POST['name'], $_POST['text']]);
    fclose($fp);
}

?>
<!DOCTYPE html>
<meta charset="UTF-8">
<title>掲示板</title>
<h1>掲示板</h1>
<section>
    <h2>新規投稿</h2>
    <form action="" method="post">
        名前: <input type="text" name="name" value=""><br>
        本文: <input type="text" name="text" value=""><br>
        <button type="submit">投稿</button>
    </form>
</section>
<section>
    <h2>投稿一覧</h2>
    <p>投稿はまだありません</p>
</section>

これで実際に投稿を実行すると,data.csvにデータが書き込まれていくのがわかると思います.

Level 2: CSVを読み出す最小限の実装

書き込む処理のみを記述しましたが,読み出す処理もあわせて実装していきます.CSVの書き込みにはfputcsv関数を用いましたが,読み出しにはfgetcsv関数を用います.

(再掲)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $fp = fopen('data.csv', 'ab');
    fputcsv($fp, [$_POST['name'], $_POST['text']]);
    fclose($fp);
}

処理の流れとしては以下のようになります.

  • 普通にアクセスされた場合,CSVを読み出して表示するだけ
  • フォーム送信された場合,新規投稿をCSV形式で追記した後,CSVを読み出して表示する

そのため,以下のような構成に変わります.

$fp = fopen('data.csv', 'a+b'); // 1
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    fputcsv($fp, [$_POST['name'], $_POST['text']]);
    rewind($fp); // 2
}
while ($row = fgetcsv($fp)) { // 3
    $rows[] = $row; // 4
}
fclose($fp);
  1. 先ほどはaモードでオープンしていましたが,読み出しも出来るようにa+モードに変更します.
  2. 書き込みを行うとファイルポインタが終端に移動してしまうので,最初から読み出すためにrewind関数を使って先頭にポインタを戻します.
  3. fgetcsv関数を使って,CSV1行を配列として取り出し,一時的に$rowに代入します.ループ毎にポインタが1行ずつ進んでいきます.これ以上取り出す行がなくなった時,falseとなります.falseになった時点でwhileブロックを脱出するようになっています.
  4. 配列として得られた$rowを,更に$rowsに代入していきます.$rowsは2次元の配列になります.これはHTMLとして表示するときに使います.

実際の全コードは以下のようになります.

このコードにはXSS脆弱性があります
<?php

$fp = fopen('data.csv', 'a+b');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    fputcsv($fp, [$_POST['name'], $_POST['text']]);
    rewind($fp);
}
while ($row = fgetcsv($fp)) {
    $rows[] = $row;
}
fclose($fp);

?>
<!DOCTYPE html>
<meta charset="UTF-8">
<title>掲示板</title>
<h1>掲示板</h1>
<section>
    <h2>新規投稿</h2>
    <form action="" method="post">
        名前: <input type="text" name="name" value=""><br>
        本文: <input type="text" name="text" value=""><br>
        <button type="submit">投稿</button>
    </form>
</section>
<section>
    <h2>投稿一覧</h2>
<?php if (!empty($rows)): ?>
    <ul>
<?php foreach ($rows as $row): ?>
        <li><?=$row[1]?> (<?=$row[0]?>)</li>
<?php endforeach; ?>
    </ul>
<?php else: ?>
    <p>投稿はまだありません</p>
<?php endif; ?>
</section>

補足説明をしておきます.

  • PHPでは制御構造に関して,if ~ endif, foreach ~ endforeach などの書き方も利用出来ます.これらはHTMLを書く部分を読みやすく出来る特性を持っています.
  • <?php echo $var; ?><?=$var?> と短く書くことが出来ます.
  • いきなりforeachで回すと$rowsが存在しない場合にエラーになるので,isset同様に変数の存在確認が出来るemptyを使っています.こちらは配列が空かどうかも確認します.

Level 3: セキュリティ対策など (前半)

以下の問題点を解決します.

  • $_POST['キー'] をそのまま参照するエラーリスク
  • 同時アクセス時のファイル破壊リスク
  • XSS脆弱性

filter_inputの導入

POSTされたデータの受け取りは,$_SERVER['REQUEST_METHOD'] === 'POST' を確認した上で

$_POST['name']

を直接参照していましたが,厳密にはこれでは不十分です.何故ならPOSTリクエストであっても$_POST['name']が存在することは保証されないからです.Webブラウザのフォームから送信せずとも,ユーザが作成したちょっとしたプログラムからもPOSTリクエストを送ることは出来ます.こういう場合にエラーになってしまうリスクを持っています.

これに対応するためには,issetを用いて変数の存在をチェックする必要があります.更に,受け取る可能性がある値には文字列配列の2種類があるので,文字列であるかどうかのチェックもis_stringで行っておきます.

if文で書く
if (isset($_POST['name']) && is_string($_POST['name'])) {
    $name = $_POST['name'];
} else {
    $name = '';
}
三項演算子で書く
$name = isset($_POST['name']) && is_string($_POST['name']) ? $_POST['name'] : '';

煩雑な処理になってきましたが,これと等価な処理はfilter_input関数を使うことで鮮やかに実現できます.

$name = (string)filter_input(INPUT_POST, 'name');

詳しくは「$_GET, $_POSTなどを受け取る際の処理」で説明していますが,ここでの説明はこれにとどめておきます.

同時アクセス対策

この状態のままでは,同時に書き込み処理があったときなどに,ファイルの形式が破壊されてしまう危険性を抱えています.対策として,flock関数によるファイルロック処理を導入します.ロックモードには2種類,補助フラグなどを含めると4種類あります.

LOCK_EX (排他ロック)

LOCK_EXまたはLOCK_SHを使おうとした,自分以外の全員を待機させます.ファイルに対する書き込みを行うときには必須です.

LOCK_SH (共有ロック)

LOCK_EXを使おうとした,自分以外の全員を待機させます.LOCK_SHを使おうとした人は待機させず,自分と一緒に読み出し作業を行うことになります.ファイルを読み出す際にはこれを用いることが多いです.

LOCK_NB (ノンブロッキングフラグ)

LOCK_EX | LOCK_NB のようにビット演算で併せて利用します.「待機」ではなく「直ちに失敗」になります.flock関数の実行自体が失敗し,falseを返すという意味です.Windowsでは使えません.

LOCK_UN (アンロック)

ロック解除です.解除し忘れても自動的にfcloseと同時にPHPがやってくれるようですが,明示的に解除しておいたほうが読みやすいコードにはなるでしょう.

XSS脆弱性対策

先ほどは投稿名や投稿内容をそのまま表示していましたが,このままでは以下のような問題点を抱えています.

  • <script>タグを用いて任意のJavaScriptコードが書き込まれてしまい,Webページが改変されたり個人情報が盗まれたりするおそれがある.
    (関連: Wikipedia - クロスサイトスクリプティング)
  • たまたま投稿者が < > & といったHTMLにおける特殊記号を書き込もうとしたとき,意図したように表示されなくなってしまう.言い方を変えれば,投稿者はただのテキストという感覚で入力しているのに,それをHTMLとして適切な形式に変換せずにそのまま表示してしまっている.
    (関連: 「何故htmlspecialcharsを通すのか?」を一言でどうぞ)

「$_GET, $_POSTなどを受け取る際の処理」でも説明していますが,対策方法をシンプルに書いておきます.

function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

このような関数を作っておき,<?=$var?> と書いている部分を <?=h($var)?> に変えるだけです.そうすれば,< > & " ' はそれぞれ &lt; &gt; &amp; &#39; &apos; に変換され,正しくテキスト状態のときのままHTMLとして表示出来るようになります.

<?php

// XSS対策のため, <?=$var?> を <?=h($var)?> にする
function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

$name = (string)filter_input(INPUT_POST, 'name'); // $_POST['name']
$text = (string)filter_input(INPUT_POST, 'text'); // $_POST['text']

$fp = fopen('data.csv', 'a+b');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    flock($fp, LOCK_EX); // 排他ロックを行う
    fputcsv($fp, [$name, $text]);
    rewind($fp);
}
flock($fp, LOCK_SH); // 共有ロックを行う,あるいは排他ロックから共有ロックに切り替える
while ($row = fgetcsv($fp)) {
    $rows[] = $row;
}
flock($fp, LOCK_UN); // ロック解除
fclose($fp);

?>
<!DOCTYPE html>
<meta charset="UTF-8">
<title>掲示板</title>
<h1>掲示板</h1>
<section>
    <h2>新規投稿</h2>
    <form action="" method="post">
        名前: <input type="text" name="name" value=""><br>
        本文: <input type="text" name="text" value=""><br>
        <button type="submit">投稿</button>
    </form>
</section>
<section>
    <h2>投稿一覧</h2>
<?php if (!empty($rows)): ?>
    <ul>
<?php foreach ($rows as $row): ?>
        <li><?=h($row[1])?> (<?=h($row[0])?>)</li>
<?php endforeach; ?>
    </ul>
<?php else: ?>
    <p>投稿はまだありません</p>
<?php endif; ?>
</section>

Level 4: セキュリティ対策など (後半)

以下の問題点を解決します.

  • CSRF脆弱性

CSRF脆弱性対策

XSS脆弱性を解決してもなお,もう1つ対策しておくべきメジャーな脆弱性があります.それがCSRF脆弱性です.本人が意図していないPOSTリクエストによる操作を,外部サイトへのリンクを踏んだだけで勝手に実行される可能性があります.犯罪予告の罠で誤認逮捕を生んだ事件もありました.
(関連: Wikipedia - クロスサイトリクエストフォージェリ)

原理や対策方法について詳しく説明すると難解になってくるので,ここでは対策方法だけを簡単にまとめておきます.興味のある方は各自調べてみてください.

  1. session_start関数を先頭で実行します.セッションはCookieという技術を応用したサーバ側でユーザの情報を管理する仕組みですが,ここではCSRF対策用途だけに限定します.
  2. session_startを実行するとsession_id関数により,ユーザを識別するための固有な文字列が取得出来るので,これをHTMLのフォームに<input type="hidden">で埋め込んでおきます.但しHTML自体が何らかの方法で流出してしまうリスクを考慮して,この値をそのまま使わずに,この値からsha1などのハッシュ関数を使ってハッシュ値をとり,それを代わりに埋め込むことにします.一般的にCSRFトークンなどと呼ばれるものです.
  3. フォームから送られてきたCSRFトークンのハッシュ値を検証し,2で作ったものと一致していれば,本人の意図通りに送信されてきたデータであることが保証されます.
<?php

function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

session_start(); // 1

$name = (string)filter_input(INPUT_POST, 'name'); 
$text = (string)filter_input(INPUT_POST, 'text');
$token = (string)filter_input(INPUT_POST, 'token'); // 3

$fp = fopen('data.csv', 'a+b');
if ($_SERVER['REQUEST_METHOD'] === 'POST' && sha1(session_id()) === $token) { // 3
    flock($fp, LOCK_EX);
    fputcsv($fp, [$name, $text]);
    rewind($fp);
}
flock($fp, LOCK_SH);
while ($row = fgetcsv($fp)) {
    $rows[] = $row;
}
flock($fp, LOCK_UN);
fclose($fp);

?>
<!DOCTYPE html>
<meta charset="UTF-8">
<title>掲示板</title>
<h1>掲示板</h1>
<section>
    <h2>新規投稿</h2>
    <form action="" method="post">
        名前: <input type="text" name="name" value=""><br>
        本文: <input type="text" name="text" value=""><br>
        <button type="submit">投稿</button>
        <input type="hidden" name="token" value="<?=h(sha1(session_id())) /*2*/ ?>">
    </form>
</section>
<section>
    <h2>投稿一覧</h2>
<?php if (!empty($rows)): ?>
    <ul>
<?php foreach ($rows as $row): ?>
        <li><?=h($row[1])?> (<?=h($row[0])?>)</li>
<?php endforeach; ?>
    </ul>
<?php else: ?>
    <p>投稿はまだありません</p>
<?php endif; ?>
</section>

セッションについては 「リクエストパラメータ・セッションに関するまとめ」 で詳しくまとめています.

参考: CSVの代わりにJSONを使う

CSV形式の場合行単位の読み書きを行いましたが,JSON形式の場合はファイル全体を一気に読み書きします.この方法は,JSON以外にもファイル全体として意味を持つあらゆる形式に応用できます.

JSON形式の一例 ({"name":"名前", "text":"本文"} の配列)
[
    {
        "name":"山田",
        "text":"こんにちは"
    },
    {
        "name":"山本",
        "text":"Hello, World"
    },
    {
        "name":"田中",
        "text":"タイキック"
    }
]

CSVの場合とJSONの場合を比べてみます.手順がかなり異なることに注目してください.

(再掲) CSVの場合
$fp = fopen('data.csv', 'a+b');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // もし投稿内容があれば,CSVとして1行追記する
    fputcsv($fp, [$_POST['name'], $_POST['text']]);
    // 追記後に,ポインタを先頭に戻す
    rewind($fp);

}
while ($row = fgetcsv($fp)) {

    // 先頭から1行ずつ読み取り,$rowsに代入する
    $rows[] = $row;

}
fclose($fp);
JSONの場合
$fp = fopen('data.json', 'a+b');

// ファイル内容全てを読み取り,JSON形式としてデコードする
// 空文字列をデコードしたときにはNULLになるので,配列にキャストして空の配列にする
$rows = (array)json_decode(stream_get_contents($fp), true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // もし投稿内容があれば,読み取った配列変数に要素を追加する
    $rows[] = ['name' => $_POST['name'], 'text' => $_POST['text']];
    // ファイルの中身をいったん全消去する (もしaモード以外でオープンしている場合rewindも必要)
    ftruncate($fp, 0);
    // ファイルにJSON形式として配列全体を書き込む (オプションは読みやすくするためのもの)
    fwrite(json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

}
fclose($fp);
JSONの場合 (別解: こちらのほうが安全でパフォーマンスも良さそうです)
$fp = fopen('data.json', 'c+b'); // cモードは任意の位置から書き込み可能

// ファイル内容全てを読み取り,JSON形式としてデコードする
// 空文字列をデコードしたときにはNULLになるので,配列にキャストして空の配列にする
$rows = (array)json_decode(stream_get_contents($fp), true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // もし投稿内容があれば,読み取った配列変数に要素を追加する
    $rows[] = ['name' => $_POST['name'], 'text' => $_POST['text']];
    // ファイルポインタを先頭に戻す
    rewind($fp);
    // ファイルにJSON形式として配列全体を上書きする (オプションは読みやすくするためのもの)
    fwrite(json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
    // 上書きされなかった後の余分なデータを消去する
    ftruncate($fp, ftell($fp));

}
fclose($fp);

もう1つ違ったJSONの使い方として,行単位のJSONというものもあります.こちらはJSON_PRETTY_PRINTを使わずに1行に押し込む形になります.取り扱いはCSVに似ているかもしれません.

{"name":"山田","text":"こんにちは"}
{"name":"山本","text":"Hello, World"}
{"name":"田中","text":"タイキック"}
(再掲) CSVの場合
$fp = fopen('data.csv', 'a+b');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // もし投稿内容があれば,CSVとして1行追記する
    fputcsv($fp, [$_POST['name'], $_POST['text']]);
    // 追記後に,ポインタを先頭に戻す
    rewind($fp);

}
while ($row = fgetcsv($fp)) {

    // 先頭から1行ずつ読み取り,$rowsに代入する
    $rows[] = $row;

}
fclose($fp);
行単位のJSONの場合
$fp = fopen('data.csv', 'a+b');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // もし投稿内容があれば,JSONとして1行追記する
    fwrite($fp, json_encode([$_POST['name'], $_POST['text']], JSON_UNESCAPED_UNICODE) . "\n");
    // 追記後に,ポインタを先頭に戻す
    rewind($fp);

}
while (false !== $row = fgets($fp)) {

    // 先頭から1行ずつ読み取り,JSONデコードした結果を$rowsに代入する
    $rows[] = json_decode($row, true);

}
fclose($fp);

CSV形式,ファイル全体としてのJSON形式,行単位のJSON形式,いずれにも長所と短所があるので,ニーズに応じて使い分けてください.

参考: その他の脆弱性への対策

Web関連の脆弱性として,有名なものを列挙してみます.

  • XSS
  • CSRF
  • SQLインジェクション
  • セッションフィクセーション
  • ディレクトリトラバーサル
  • OSコマンドインジェクション
  • DNSリバインディング
  • オープンリダイレクタ

XSSとCSRFについては既に触れましたが,ここではかなり浅く他の2つの脆弱性についても紹介しておきます.

SQLインジェクション

今回はCSVにデータを記録しましたが,こういった普通のファイルでのデータ管理には限界があり,とりわけデータ検索がしづらいなど大きな欠点も抱えています.こういう問題に対処するため,世の中の多くのアプリケーションではリレーショナルデータベースという工夫されたシステムが採用されており,これをあらゆるプログラミング言語から操作するための問い合わせ言語としてSQLという共通言語が存在しています.もちろんPHPから利用することも出来ます.

[例] テーブルpeopleから,nameがJohnである人のageを抜き出すSQL文
SELECT age FROM people WHERE name = 'John'

この文をPHPで動的に作成するとき,'John'に相当する部分にPHPの変数を当てるような場合もあるでしょう.

$sql = "SELECT age FROM people WHERE name = '{$_GET['name']}'";

ところが,このコードは $_GET['name']'が含まれない前提となっており,悪意のある送信者が'を含む値を送信してきたとき,SQL文は破壊されてしまいます.

常に真になる条件に改変することが出来る例
$_GET['name'] = "' OR 1 = 1";
$sql = "SELECT age FROM people WHERE name = '{$_GET['name']}'";
echo $sql; // SELECT age FROM people WHERE name = '' OR 1 = 1'

これはまだマシな例ですが,悪用すると個人情報を盗み出したり破壊することも出来てしまいます.対処するためには,SQL文をプレースホルダと呼ばれるものを用いて組み立てる必要があります.この記事ではこれ以上の説明は割愛しますので,リレーショナルデータベースが必要になったタイミングで調べてみてください.

プレースホルダは「?」で表される
$sql = "SELECT age FROM people WHERE name = ?";

これだけは覚える: SQL文の組み立てには「プレースホルダ」を利用する

セッションフィクセーション

先ほどCSRF対策のところでsession_id関数を使ってユーザ固有の識別子を取得していました.PHPでログイン可能なサイトを作る時には必ずと言っていいほどセッションを利用しますが,対策を施しておかないと,何らかの手段でログイン前の状態のセッションIDが他人に知られてしまっていたときに,ログイン後の状態を他人に乗っ取られてしまいます.対策として,ログイン成功後にセッションIDを変えてしまうという手段が有効です.

これだけは覚える: ログイン成功後にはセッションIDを再生成する

ログイン成功後に実行すべきコード
session_regenerate_id(true);