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形式以外にも,行単位で意味を持つあらゆる形式に応用出来ます.
山田,こんにちは
山本,"Hello, World"
田中,タイキック
ファイルに書き込むために最低限必要な流れは以下のようになります.
- ファイル名とファイルオープンモードを指定して,
fopen
関数でファイルを操作するための識別子(リソース)を取得する. - 文字列をそのまま書き込むためには
fwrite
関数,配列をCSV形式に変換して1行として書き込むためにはfputcsv
関数を利用する. -
fclose
関数でファイルを閉じる.(これは省略可能であるが,今回は丁寧に記述する)
ファイルオープンモードについて,「ファイルオープンモードに関するマニュアルの記述は間違っている」で触れていることについても最小限の説明を行っておきます.下図で登場する「ポインタ」は,テキストエディタで言えばいわゆる「点滅する縦棒」に相当するものです.編集中の位置を表しています.また,✕
はそのモードでは実行できない操作であることを示しています.
今回要求しているのは
- 書き込み自体がまず出来ること
- 新しい書き込みがファイルの終端に次々に書き足されていくこと
なので,採用すべきモードは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);
- 先ほどは
a
モードでオープンしていましたが,読み出しも出来るようにa+
モードに変更します. - **書き込みを行うとファイルポインタが終端に移動してしまう**ので,最初から読み出すために
rewind
関数を使って先頭にポインタを戻します. -
fgetcsv
関数を使って,CSV1行を配列として取り出し,一時的に$row
に代入します.ループ毎にポインタが1行ずつ進んでいきます.これ以上取り出す行がなくなった時,false
となります.false
になった時点でwhile
ブロックを脱出するようになっています. - 配列として得られた
$row
を,更に$rows
に代入していきます.$rows
は2次元の配列になります.これはHTMLとして表示するときに使います.
実際の全コードは以下のようになります.
<?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 (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)?>
に変えるだけです.そうすれば,<
>
&
"
'
はそれぞれ <
>
&
'
'
に変換され,正しくテキスト状態のときのまま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 - クロスサイトリクエストフォージェリ)
原理や対策方法について詳しく説明すると難解になってくるので,ここでは対策方法だけを簡単にまとめておきます.興味のある方は各自調べてみてください.
-
session_start
関数を先頭で実行します.セッションはCookieという技術を応用したサーバ側でユーザの情報を管理する仕組みですが,ここではCSRF対策用途だけに限定します. -
session_start
を実行するとsession_id
関数により,ユーザを識別するための固有な文字列が取得出来るので,これをHTMLのフォームに<input type="hidden">
で埋め込んでおきます.但しHTML自体が何らかの方法で流出してしまうリスクを考慮して,この値をそのまま使わずに,この値からsha1
などのハッシュ関数を使ってハッシュ値をとり,それを代わりに埋め込むことにします.一般的にCSRFトークンなどと呼ばれるものです. - フォームから送られてきた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以外にもファイル全体として意味を持つあらゆる形式に応用できます.
[
{
"name":"山田",
"text":"こんにちは"
},
{
"name":"山本",
"text":"Hello, World"
},
{
"name":"田中",
"text":"タイキック"
}
]
CSVの場合とJSONの場合を比べてみます.手順がかなり異なることに注目してください.
$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);
$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($fp, json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
fclose($fp);
$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($fp, 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":"タイキック"}
$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);
$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から利用することも出来ます.
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);