以前にこちらの記事で掲示板アプリの作成方法を解説しました。
記事内でもお伝えしているように、上の記事で解説している掲示板アプリではセキュリティ対策はしておらず、本番運用には不向きの状態です。
今回は、この掲示板アプリにセキュリティ対策の処理を加え、それについて解説をしていきたいと思います。
純粋な掲示板アプリの解説が読みたい場合、まずは上記の記事をご覧下さい!
その上で当記事のセキュリティ対策編をお読みいただくことでご自身でも安全なアプリケーション開発を行うポイントを学ぶことが可能です。
- XSS対策方法・攻撃内容
- CSRF対策方法(トークンの利用)・攻撃内容
- SQLインジェクション対策方法・攻撃内容
- セッションハイジャック対策方法・攻撃内容
なお、この記事のセキュリティ対策は以下の書籍を参考にしています。
(通称:徳丸本と呼ばれる「Webアプリ開発者必読」とまで言われている書籍です)
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 脆弱性が生まれる原理と対策の実践
徳丸 浩/著 SBクリエイティブ/出版│Amazon
掲示板アプリの全体コード
元々の掲示板アプリのコード
board.php(掲示板トップ)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* 投稿者ID(20桁)を生成
*/
if (isset($_SESSION['cont_id'])) {
$cont_id = $_SESSION['cont_id'];
} else {
$_SESSION['cont_id'] =
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90));
$cont_id = $_SESSION['cont_id'];
}
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 投稿ボタンが押下されたときの処理
*/
if (isset($_POST['post_btn'])) {
// 更新操作用の処理
unset($_SESSION['id']);
/**
* セッション変数に情報を保存して
* タイトルまたは投稿内容の片方だけが
* 入力されていた場合、
* 入力フォームに内容を保持する
*/
if (isset($_POST['post_title']) && $_POST['post_title'] != '') {
$_SESSION['title'] = $_POST['post_title'];
} else {
unset($_SESSION['title']);
}
if (isset($_POST['post_comment']) && $_POST['post_comment'] != '') {
$_SESSION['comment'] = $_POST['post_comment'];
} else {
unset($_SESSION['comment']);
}
/**
* エラーメッセージ格納
*/
if ($_POST['post_title'] == '') $err_msg_title = '※タイトルを入力して下さい';
if ($_POST['post_comment'] == '') $err_msg_comment = '※投稿内容を入力して下さい';
/**
* 必要項目がすべて入力されてたら投稿処理を実行
*/
if (
isset($_POST['post_title']) && $_POST['post_title'] != '' &&
isset($_POST['post_comment']) && $_POST['post_comment'] != ''
) {
$title = $_POST['post_title'];
$comment = $_POST['post_comment'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容登録処理
*/
$sql = ('
INSERT INTO
board_info (title, comment, contributor_id)
VALUES
(:TITLE, :COMMENT, :CONTRIBUTOR_ID)
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
$stmt->bindValue(':CONTRIBUTOR_ID', $cont_id, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// 投稿に成功したらセッション変数を破棄
unset($_SESSION['title']);
unset($_SESSION['comment']);
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 投稿一覧取得処理
*/
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
$sql = ('
SELECT *
FROM board_info
ORDER BY id DESC
');
$stmt = $pdo->prepare($sql);
// SQL実行
$stmt->execute();
// 投稿情報を辞書形式ですべて取得
$post_list = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
?>
<!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>
<h1>掲示板アプリ</h1>
<!-- 投稿フォーム -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<label>
<p>タイトル(※最大30文字)</p>
<input type="text" name="post_title" value="<?php if (isset($_SESSION['title'])) echo $_SESSION['title']; ?>">
<!-- エラーメッセージ -->
<?php if (isset($err_msg_title)) {
echo "<p class='err'>{$err_msg_title}</p>";
} ?>
</label>
</div>
<div>
<label>
<p>投稿内容(※最大1000文字)</p>
<textarea name="post_comment" cols="50" rows="10"><?php if (isset($_SESSION['comment'])) echo $_SESSION['comment']; ?></textarea>
<!-- エラーメッセージ -->
<?php if (isset($err_msg_comment)) {
echo "<p class='err'>{$err_msg_comment}</p>";
} ?>
</label>
</div>
</div>
<button class="btn--mg-c" type="submit" name="post_btn" value="post_btn">投稿</button>
</form>
</section>
<hr>
<!-- 投稿一覧 -->
<section class="post-list">
<?php if (count($post_list) === 0) : ?>
<!-- 投稿が無いときはメッセージを表示する -->
<p class="no-post-msg">現在、投稿はありません。</p>
<?php else : ?>
<ul>
<!-- 投稿情報の出力 -->
<?php foreach ($post_list as $post_item) : ?>
<li>
<form action="" method="post">
<!-- 投稿ID -->
<span>ID:<?php echo $post_item['id']; ?> </span>
<!-- 投稿タイトル -->
<span><?php echo $post_item['title']; ?></span>
<!-- 投稿者ID -->
<span>/投稿者:<?php echo $post_item['contributor_id']; ?></span>
<!-- 投稿内容 -->
<p class="p-pre"><?php echo $post_item['comment']; ?></p>
<!-- 投稿日時 -->
<span class="post-datetime">投稿日時:<?php echo $post_item['created_at']; ?></span>
<!-- 過去に更新されていたら更新日時も表示 -->
<?php if ($post_item['created_at'] < $post_item['updated_at']) : ?>
<span class="post-datetime post-datetime__updated">更新日時:<?php echo $post_item['updated_at']; ?></span>
<?php endif; ?>
</form>
<!-- 自分の投稿内容かつセッションが有効な間は編集・削除が可能 -->
<?php if ($post_item['contributor_id'] === $cont_id) : ?>
<div class="btn-flex">
<form action="update-edit.php" method="post">
<button type="submit" name="update_btn">編集</button>
<input type="hidden" name="post_id" value="<?php echo $post_item['id']; ?>">
</form>
<form action="delete-confirm.php" method="post">
<button type="submit" name="delete_btn">削除</button>
<input type="hidden" name="post_id" value="<?php echo $post_item['id']; ?>">
</form>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['id']) && ($_SESSION['id'] == $post_item['id'])): ?>
<p class='updated-post'>更新しました</p>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
</body>
</html>
update-edit.php(編集画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 編集ボタンで遷移してきたときの処理
*/
if (isset($_POST['update_btn'])) {
/**
* 編集対象の投稿情報を取得
*/
if (isset($_POST['post_id']) && $_POST['post_id'] != '') {
// セッションに投稿IDを保持
$_SESSION['id'] = $_POST['post_id'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容取得処理
*/
$sql = ('
SELECT id, title, comment
FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 投稿情報の取得
$post_info = $stmt->fetch();
$_SESSION['title'] = $post_info['title'];
$_SESSION['comment'] = $post_info['comment'];
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 更新ボタンが押下されたときの処理
*/
if (isset($_POST['update_submit_btn'])) {
/**
* セッション変数に情報を保存して
* タイトルまたは投稿内容の片方だけが
* 入力されていた場合、
* 入力フォームに内容を保持する
*/
if (isset($_POST['post_title']) && $_POST['post_title'] != '') {
$_SESSION['title'] = $_POST['post_title'];
} else {
unset($_SESSION['title']);
}
if (isset($_POST['post_comment']) && $_POST['post_comment'] != '') {
$_SESSION['comment'] = $_POST['post_comment'];
} else {
unset($_SESSION['comment']);
}
/**
* エラーメッセージ格納
*/
if ($_POST['post_title'] == '') $err_msg_title = '※タイトルを入力して下さい';
if ($_POST['post_comment'] == '') $err_msg_comment = '※投稿内容を入力して下さい';
/**
* 必要項目がすべて入力されてたら更新処理を実行
*/
if (
isset($_POST['post_title']) && $_POST['post_title'] != '' &&
isset($_POST['post_comment']) && $_POST['post_comment'] != ''
) {
$title = $_POST['post_title'];
$comment = $_POST['post_comment'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容更新処理
*/
$sql = ('
UPDATE board_info
SET title = :TITLE, comment = :COMMENT
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// 更新に成功したらセッション変数を破棄
// unset($_SESSION['id']); // ※投稿IDは敢えて破棄せず、掲示板ページでID判定をするために情報を保持する★
unset($_SESSION['title']);
unset($_SESSION['comment']);
// 掲示板ページへ戻る
header('Location: board.php');
exit();
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* キャンセルボタンが押下されたら
* セッション情報を破棄して
* 掲示板一覧画面へ戻る
*/
if (isset($_POST['cancel_btn'])) {
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
header('Location: board.php');
exit();
}
?>
<!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>
<h1>投稿編集画面</h1>
<!-- 投稿編集フォーム -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<label>
<p>タイトル</p>
<input type="text" name="post_title" value="<?php if (isset($_SESSION['title'])) echo $_SESSION['title']; ?>">
<!-- エラーメッセージ -->
<?php if (isset($err_msg_title)) {
echo "<p class='err'>{$err_msg_title}</p>";
} ?>
</label>
</div>
<div>
<label>
<p>投稿内容</p>
<textarea name="post_comment" cols="50" rows="10"><?php if (isset($_SESSION['comment'])) echo $_SESSION['comment']; ?></textarea>
<!-- エラーメッセージ -->
<?php if (isset($err_msg_comment)) echo "<p class='err'>{$err_msg_comment}</p>"; ?>
</label>
</div>
</div>
<div class="btn-flex">
<button type="submit" name="update_submit_btn" value="update_submit_btn">更新</button>
<button type="submit" name="cancel_btn" value="cancel_btn">キャンセル</button>
</div>
</form>
</section>
</body>
</html>
delete-confirm.php(削除確認画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 削除ボタンで遷移してきたときの処理
*/
if (isset($_POST['delete_btn'])) {
/**
* 編集対象の投稿情報を取得
*/
if (isset($_POST['post_id']) && $_POST['post_id'] != '') {
// セッションに投稿IDを保持
$_SESSION['id'] = $_POST['post_id'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容登録処理
*/
$sql = ('
SELECT id, title, comment
FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 投稿情報の取得
$post_info = $stmt->fetch();
$_SESSION['title'] = $post_info['title'];
$_SESSION['comment'] = $post_info['comment'];
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 削除ボタンが押下されたときの処理
*/
if (isset($_POST['delete_submit_btn'])) {
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容削除処理
*/
$sql = ('
DELETE FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 削除に成功したらセッション変数を破棄
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
// 削除成功画面へ遷移
header('Location: delete-success.php');
exit();
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
/**
* キャンセルボタンが押下されたら
* セッション情報を破棄して
* 掲示板一覧画面へ戻る
*/
if (isset($_POST['cancel_btn'])) {
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
header('Location: board.php');
return;
}
?>
<!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>
<h1>削除確認</h1>
<p class="delete-confirm-msg">以下の投稿を削除します。</p>
<!-- 削除確認画面 -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<p>タイトル</p>
<p><?php if (isset($_SESSION['title'])) echo $_SESSION['title']; ?></p>
</div>
<div>
<p>投稿内容</p>
<p class="p-pre"><?php if (isset($_SESSION['comment'])) echo $_SESSION['comment']; ?></p>
</div>
</div>
<div class="btn-flex">
<button type="submit" name="delete_submit_btn" value="delete_submit_btn">削除</button>
<button type="submit" name="cancel_btn" value="cancel_btn">キャンセル</button>
</div>
</form>
</section>
</body>
</html>
delete-success.php(削除成功画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* 掲示場TOPへ自動で遷移する処理★
*/
header('refresh: 3; url=board.php');
?>
<!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>
<h1>削除が完了しました。</h1>
<p class="delete-success-msg">3秒後に自動で掲示板TOPへ戻ります。</p>
</body>
</html>
セキュリティ対策を施したコード
board.php(掲示板トップ)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
// ★【セッションハイジャック】セッションIDを新しいものに置き換える
session_regenerate_id();
/**
* 投稿者ID(20桁)を生成
*/
if (isset($_SESSION['cont_id'])) {
$cont_id = $_SESSION['cont_id'];
} else {
$_SESSION['cont_id'] =
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90));
$cont_id = $_SESSION['cont_id'];
}
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_path';
/**
* 投稿ボタンが押下されたときの処理
*/
if (isset($_POST['post_btn'])) {
// ★【CSRF】トークンチェック
if(empty($_SESSION['board_token']) || ($_SESSION['board_token'] !== $_POST['board_token'])){
exit('不正な投稿です');
}
if(isset($_SESSION['board_token'])) unset($_SESSION['board_token']);//トークン破棄
if(isset($_POST['board_token'])) unset($_POST['board_token']);//トークン破棄
// 更新操作用の処理
unset($_SESSION['id']);
/**
* セッション変数に情報を保存して
* タイトルまたは投稿内容の片方だけが
* 入力されていた場合、
* 入力フォームに内容を保持する
*/
if (isset($_POST['post_title']) && $_POST['post_title'] != '') {
$_SESSION['title'] = $_POST['post_title'];
} else {
unset($_SESSION['title']);
}
if (isset($_POST['post_comment']) && $_POST['post_comment'] != '') {
$_SESSION['comment'] = $_POST['post_comment'];
} else {
unset($_SESSION['comment']);
}
/**
* エラーメッセージ格納
*/
if ($_POST['post_title'] == '') $err_msg_title = '※タイトルを入力して下さい';
if ($_POST['post_comment'] == '') $err_msg_comment = '※投稿内容を入力して下さい';
/**
* 必要項目がすべて入力されてたら投稿処理を実行
*/
if (
isset($_POST['post_title']) && $_POST['post_title'] != '' &&
isset($_POST['post_comment']) && $_POST['post_comment'] != ''
) {
$title = $_POST['post_title'];
$comment = $_POST['post_comment'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
/**
* 投稿内容登録処理
*/
$sql = ('
INSERT INTO
board_info (title, comment, contributor_id)
VALUES
(:TITLE, :COMMENT, :CONTRIBUTOR_ID)
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
$stmt->bindValue(':CONTRIBUTOR_ID', $cont_id, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// 投稿に成功したらセッション変数を破棄
unset($_SESSION['title']);
unset($_SESSION['comment']);
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 投稿一覧取得処理
*/
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
$sql = ('
SELECT *
FROM board_info
ORDER BY id DESC
');
$stmt = $pdo->prepare($sql);
// SQL実行
$stmt->execute();
// 投稿情報を辞書形式ですべて取得
$post_list = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
?>
<!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>
<h1>掲示板アプリ</h1>
<!-- 投稿フォーム -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<label>
<p>タイトル(※最大30文字)</p>
<input type="text" name="post_title" value="<?php if (isset($_SESSION['title'])) echo htmlspecialchars($_SESSION['title'], ENT_QUOTES, 'UTF-8'); ?>">
<!-- エラーメッセージ -->
<?php if (isset($err_msg_title)) {
echo htmlspecialchars("<p class='err'>{$err_msg_title}</p>", ENT_QUOTES, 'UTF-8');
} ?>
</label>
</div>
<div>
<label>
<p>投稿内容(※最大1000文字)</p>
<textarea name="post_comment" cols="50" rows="10"><?php if (isset($_SESSION['comment'])) echo htmlspecialchars($_SESSION['comment'], ENT_QUOTES, 'UTF-8'); ?></textarea>
<!-- エラーメッセージ -->
<?php if (isset($err_msg_comment)) echo htmlspecialchars("<p class='err'>{$err_msg_comment}</p>", ENT_QUOTES, 'UTF-8');; ?>
</label>
</div>
</div>
<?php
//★ 不正リクエストチェック用のトークン生成
$token = sha1(uniqid(mt_rand(), true));
$_SESSION['board_token'] = $token;
echo '<input type="hidden" name="board_token" value="'.$token.'" />';
?>
<button class="btn--mg-c" type="submit" name="post_btn" value="post_btn">投稿</button>
</form>
</section>
<hr>
<!-- 投稿一覧 -->
<section class="post-list">
<?php if (count($post_list) === 0) : ?>
<!-- 投稿が無いときはメッセージを表示する -->
<p class="no-post-msg">現在、投稿はありません。</p>
<?php else : ?>
<ul>
<!-- 投稿情報の出力 -->
<?php foreach ($post_list as $post_item) : ?>
<li>
<form action="" method="post">
<!-- 投稿ID -->
<span>ID:<?php echo htmlspecialchars($post_item['id'], ENT_QUOTES, 'UTF-8'); ?> </span>
<!-- 投稿タイトル -->
<span><?php echo htmlspecialchars($post_item['title'], ENT_QUOTES, 'UTF-8'); ?></span>
<!-- 投稿者ID -->
<span>/投稿者:<?php echo htmlspecialchars($post_item['contributor_id'], ENT_QUOTES, 'UTF-8'); ?></span>
<!-- 投稿内容 -->
<p class="p-pre"><?php echo htmlspecialchars($post_item['comment'], ENT_QUOTES, 'UTF-8'); ?></p>
<!-- 投稿日時 -->
<span class="post-datetime">投稿日時:<?php echo htmlspecialchars($post_item['created_at'], ENT_QUOTES, 'UTF-8'); ?></span>
<!-- 過去に更新されていたら更新日時も表示 -->
<?php if ($post_item['created_at'] < $post_item['updated_at']) : ?>
<span class="post-datetime post-datetime__updated">更新日時:<?php echo htmlspecialchars($post_item['updated_at'], ENT_QUOTES, 'UTF-8'); ?></span>
<?php endif; ?>
</form>
<!-- 自分の投稿内容かつセッションが有効な間は編集・削除が可能 -->
<?php if ($post_item['contributor_id'] === $cont_id) : ?>
<div class="btn-flex">
<form action="update-edit.php" method="post">
<button type="submit" name="update_btn">編集</button>
<input type="hidden" name="post_id" value="<?php echo htmlspecialchars($post_item['id'], ENT_QUOTES, 'UTF-8'); ?>">
</form>
<form action="delete-confirm.php" method="post">
<button type="submit" name="delete_btn">削除</button>
<input type="hidden" name="post_id" value="<?php echo htmlspecialchars($post_item['id'], ENT_QUOTES, 'UTF-8'); ?>">
</form>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['id']) && ($_SESSION['id'] == $post_item['id'])) : ?>
<p class='updated-post'>更新しました</p>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
</body>
</html>
update-edit.php(編集画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
// ★【セッションハイジャック】セッションIDを新しいものに置き換える
session_regenerate_id();
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_path';
/**
* 編集ボタンで遷移してきたときの処理
*/
if (isset($_POST['update_btn'])) {
/**
* 編集対象の投稿情報を取得
*/
if (isset($_POST['post_id']) && $_POST['post_id'] != '') {
// セッションに投稿IDを保持
$_SESSION['id'] = $_POST['post_id'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
/**
* 投稿内容登録処理
*/
$sql = ('
SELECT id, title, comment
FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 投稿情報の取得
$post_info = $stmt->fetch();
$_SESSION['title'] = $post_info['title'];
$_SESSION['comment'] = $post_info['comment'];
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 更新ボタンが押下されたときの処理
*/
if (isset($_POST['update_submit_btn'])) {
// ★【CSRF】トークンチェック
if (empty($_SESSION['board_token']) || ($_SESSION['board_token'] !== $_POST['board_token'])) {
exit('不正な投稿です');
}
if (isset($_SESSION['board_token'])) unset($_SESSION['board_token']); //トークン破棄
if (isset($_POST['board_token'])) unset($_POST['board_token']); //トークン破棄
/**
* セッション変数に情報を保存して
* タイトルまたは投稿内容の片方だけが
* 入力されていた場合、
* 入力フォームに内容を保持する
*/
if (isset($_POST['post_title']) && $_POST['post_title'] != '') {
$_SESSION['title'] = $_POST['post_title'];
} else {
unset($_SESSION['title']);
}
if (isset($_POST['post_comment']) && $_POST['post_comment'] != '') {
$_SESSION['comment'] = $_POST['post_comment'];
} else {
unset($_SESSION['comment']);
}
/**
* エラーメッセージ格納
*/
if ($_POST['post_title'] == '') $err_msg_title = '※タイトルを入力して下さい';
if ($_POST['post_comment'] == '') $err_msg_comment = '※投稿内容を入力して下さい';
/**
* 必要項目がすべて入力されてたら投稿処理を実行
*/
if (
isset($_POST['post_title']) && $_POST['post_title'] != '' &&
isset($_POST['post_comment']) && $_POST['post_comment'] != ''
) {
$title = $_POST['post_title'];
$comment = $_POST['post_comment'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
/**
* 投稿内容登録処理
*/
$sql = ('
UPDATE board_info
SET title = :TITLE, comment = :COMMENT
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// 投稿に成功したらセッション変数を破棄
// unset($_SESSION['id']); // ※投稿IDは敢えて破棄せず、掲示板ページでID判定をするために情報を保持する
unset($_SESSION['title']);
unset($_SESSION['comment']);
// 掲示板ページへ戻る
header('Location: board.php');
exit();
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* キャンセルボタンが押下されたら
* セッション情報を破棄して
* 掲示板一覧画面へ戻る
*/
if (isset($_POST['cancel_btn'])) {
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
header('Location: board.php');
exit();
}
?>
<!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>
<h1>投稿編集画面</h1>
<!-- 投稿編集フォーム -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<label>
<p>タイトル</p>
<input type="text" name="post_title" value="<?php if (isset($_SESSION['title'])) echo htmlspecialchars($_SESSION['title'], ENT_QUOTES, 'UTF-8'); ?>">
<!-- エラーメッセージ -->
<?php if (isset($err_msg_title)) {
echo htmlspecialchars("<p class='err'>{$err_msg_title}</p>", ENT_QUOTES, 'UTF-8');
} ?>
</label>
</div>
<div>
<label>
<p>投稿内容</p>
<textarea name="post_comment" cols="50" rows="10"><?php if (isset($_SESSION['comment'])) echo htmlspecialchars($_SESSION['comment'], ENT_QUOTES, 'UTF-8'); ?></textarea>
<!-- エラーメッセージ -->
<?php if (isset($err_msg_comment)) echo htmlspecialchars("<p class='err'>{$err_msg_comment}</p>", ENT_QUOTES, 'UTF-8'); ?>
</label>
</div>
</div>
<?php
//★ 不正リクエストチェック用のトークン生成
$token = sha1(uniqid(mt_rand(), true));
$_SESSION['board_token'] = $token;
echo '<input type="hidden" name="board_token" value="'.$token.'" />';
?>
<div class="btn-flex">
<button type="submit" name="update_submit_btn" value="update_submit_btn">更新</button>
<button type="submit" name="cancel_btn" value="cancel_btn">キャンセル</button>
</div>
</form>
</section>
</body>
</html>
delete-confirm.php(削除確認画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
// ★【セッションハイジャック】セッションIDを新しいものに置き換える
session_regenerate_id();
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_path';
/**
* 削除ボタンで遷移してきたときの処理
*/
if (isset($_POST['delete_btn'])) {
/**
* 編集対象の投稿情報を取得
*/
if (isset($_POST['post_id']) && $_POST['post_id'] != '') {
// セッションに投稿IDを保持
$_SESSION['id'] = $_POST['post_id'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
/**
* 投稿内容登録処理
*/
$sql = ('
SELECT id, title, comment
FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 投稿情報の取得
$post_info = $stmt->fetch();
$_SESSION['title'] = $post_info['title'];
$_SESSION['comment'] = $post_info['comment'];
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 削除ボタンが押下されたときの処理
*/
if (isset($_POST['delete_submit_btn'])) {
// ★【CSRF】トークンチェック
if (empty($_SESSION['board_token']) || ($_SESSION['board_token'] !== $_POST['board_token'])) {
exit('不正な投稿です');
}
if (isset($_SESSION['board_token'])) unset($_SESSION['board_token']); //トークン破棄
if (isset($_POST['board_token'])) unset($_POST['board_token']); //トークン破棄
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
/**
* 投稿内容削除処理
*/
$sql = ('
DELETE FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 削除に成功したらセッション変数を破棄
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
// 削除成功画面へ遷移
header('Location: delete-success.php');
exit();
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
/**
* キャンセルボタンが押下されたら
* セッション情報を破棄して
* 掲示板一覧画面へ戻る
*/
if (isset($_POST['cancel_btn'])) {
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
header('Location: board.php');
return;
}
?>
<!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>
<h1>削除確認</h1>
<p class="delete-confirm-msg">以下の投稿を削除します。</p>
<!-- 削除確認画面 -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<p>タイトル</p>
<p><?php if (isset($_SESSION['title'])) echo htmlspecialchars($_SESSION['title'], ENT_QUOTES, 'UTF-8'); ?></p>
</div>
<div>
<p>投稿内容</p>
<p class="p-pre"><?php if (isset($_SESSION['comment'])) echo htmlspecialchars($_SESSION['comment'], ENT_QUOTES, 'UTF-8'); ?></p>
</div>
</div>
<?php
//★【CSRF】 不正リクエストチェック用のトークン生成
$token = sha1(uniqid(mt_rand(), true));
$_SESSION['board_token'] = $token;
echo '<input type="hidden" name="board_token" value="'.$token.'" />';
?>
<div class="btn-flex">
<button type="submit" name="delete_submit_btn" value="delete_submit_btn">削除</button>
<button type="submit" name="cancel_btn" value="cancel_btn">キャンセル</button>
</div>
</form>
</section>
</body>
</html>
delete-success.php(削除成功画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* 掲示場TOPへ自動で遷移する処理
*/
header('refresh: 3; url=board.php');
?>
<!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>
<h1>削除が完了しました。</h1>
<p class="delete-success-msg">3秒後に自動で掲示板TOPへ戻ります。</p>
</body>
</html>
その辺りも含め、これから詳しく解説をしていきます。
【解説】掲示板アプリで実施したセキュリティ対策
今回のコードでは、以下の4種類のセキュリティ対策を実施しました。
- XSS(クロスサイト・スクリプティング)
- CSRF(クロスサイト・リクエスト・フォージェリ)
- SQLインジェクション
- セッションハイジャック
それぞれのセキュリティ攻撃ついての詳しい解説は(セッションハイジャックを除き)以下の記事でまとめています。
とは言え、まったく知識が無いとプログラムコードを見ても何をしているのかイメージしづらくなってしまうため、各セキュリティ攻撃について簡単にご説明します。
XSS(クロスサイト・スクリプティング)
XSS(クロスサイト・スクリプティング)は、Webサイト内に悪意あるスクリプトを埋め込む攻撃です。
例えば、悪意あるスクリプトが埋め込まれているサイトへユーザがアクセスしてしまうと、そのユーザのブラウザ上で悪意のあるスクリプトが実行され、個人情報漏洩などに繋がります。
掲示板アプリで言うと、投稿内にスクリプトを埋め込まれるイメージです。
ただ掲示板アプリを利用したいだけのユーザからしたら堪ったものではないですね……。
詳しくは後述しますが
XSS(クロスサイト・スクリプティング)対策のキーワードは「エスケープ処理」です。
CSRF(クロスサイト・リクエスト・フォージェリ)
CSRF(クロスサイト・リクエスト・フォージェリ)は、悪意あるスクリプトが含まれているボタンやリンクをユーザにクリックさせることで、攻撃対象のサイトへ何らかの攻撃を仕掛けるセキュリティ攻撃です。
例えば、迷惑メールのリンクをクリックした先に罠サイトがあり、その中のボタンなどを押下してしまうと、攻撃対象の掲示板サイトに不正投稿が行われてしまうといった攻撃です。
ユーザからすると、例えば知らない内に「脅迫的内容の投稿」をしていたことになっていて、突然逮捕されてしまうといった事態になったりします。
この対策を怠ると被害を被るのは運営者だけではないということですね。
また、掲示板サイトでCSRF対策を怠ると、簡単に外部サイトから投稿することができてしまい、一般ユーザに多大な迷惑がかかります。
詳しくは後述しますが
CSRF攻撃へ有効な対策は「トークン認証」です。
SQLインジェクション
SQLインジェクションはデータベース処理の脆弱性をつき、不正なSQLを注入(インジェクション)することでログイン情報などを抜き取る攻撃手法です。
本掲示板アプリでは個人情報は保持しない仕様ですが、SQLインジェクションを許容してしまうと最悪の場合、投稿内容をすべて削除されてしまう事態にもなりかねないため対策が欠かせません。
ただ、実はこちらのSQLインジェクション対策については、元々の掲示板アプリ記事の時点ですでに処理が実装されています。
それに加えてPDOクラスを扱う際のオプションのひとつを追加しただけという形になります。
セッションハイジャック
セッションハイジャックは、文字通りセッションを乗っ取る攻撃手法です。
本掲示板アプリではセッションを利用したユーザ識別を行ない、それにより編集や削除機能を有効化しています。
つまり
セッションを乗っ取られたら、簡単に投稿内容を編集されたり削除されたりしてしまい、ユーザが大きな被害を被ることになってしまいます。
セッションハイジャックが起こる原因はいくつかありますが
有効な対処法は「ページ遷移の度にセッションIDを刷新する」
です。
これにより、仮にセッションを一時的にでも乗っ取られてもページ遷移した際にセッションIDが新しくなるため、ハイジャックは解除されます。
【解説】セキュリティ対策ごとのソースコード
ここからは実際のコードを見ながらセキュリティ対策について解説をしていきます。
XSS(クロスサイト・スクリプティング)
まずはXSS対策からです。
こちらの対策箇所は唯一★マークを付けていませんが、方法はシンプルで、掲示板上で何かしら出力する部分に対してhtmlspecialchars関数を利用し、エスケープ処理を施しています。
htmlspecialchars($post_item['title'], ENT_QUOTES, 'UTF-8');
こちらは、ユーザが掲示板へ投稿した投稿のタイトル($post_item[‘title])を出力する箇所の記述です。
通常であれば、$post_item[‘title]と記述していたところに、htmlspecialcharsというPHPの関数で囲ってあげているという形です。
これにより、$post_item[‘title]をエスケープ処理し、不正な入力値(スクリプト)が紛れていたとしてもそれをただの文字列として表示させる=実行させないようにすることが可能です。
引数が後ろに2つ付いていますが、第二引数のENT_QUOTESはどの文字までをエスケープ対象とするかを表しています。
第三引数は文字コードUTF-8を基準にエスケープするという意味です。
HTMLの文字形式(<meta charset=”UTF-8″>の部分)をUTF-8に指定することが推奨されます。指定しないと、攻撃者にUTF-8以外の別の文字コードに書き替えられ、htmlspecialchars関数を無効化されてしまう恐れがあります。
以上がXSS対策です。
何かしら出力する箇所はくまなくhtmlspecialchars関数でエスケープする
CSRF(クロスサイト・リクエスト・フォージェリ)
続くCSRFでのキーワードは「トークン認証」です。
トークン認証とは、いわば合言葉で正しいリクエストかどうかを認証するということです。
先ほど簡単にCSRFについてご説明をしましたが、CSRFでは攻撃対象のサイトへ不正リクエストを送信し、意図しない動作をさせます。
つまり、攻撃を受ける側でリクエストを正しく振り分けできていれば、この攻撃は阻止できるのです。
そこで使用するのがトークンと呼ばれるランダムな文字列です。
これを実際の処理で見ると、以下のようなものになっています。
トークンを利用した処理
<?php
//★ 不正リクエストチェック用のトークン生成
$token = sha1(uniqid(mt_rand(), true));
$_SESSION['board_token'] = $token;
echo '<input type="hidden" name="board_token" value="'.$token.'" />';
>
この処理は、「投稿ボタンform」の中に組み込まれています。
つまり、投稿ボタンを押下すると、①hidden属性で隠されたトークンが一緒にPOST送信されます。
また、その直前で②セッション変数に格納しています。
そして、①と②を、実際に投稿処理を行う前に比較(以下の処理)し、一致すれば正しいリクエストであると判断するという流れです。
// ★【CSRF】トークンチェック
if(empty($_SESSION['board_token']) || ($_SESSION['board_token'] !== $_POST['board_token'])){
exit('不正な投稿です');
}
if(isset($_SESSION['board_token'])) unset($_SESSION['board_token']);//トークン破棄
if(isset($_POST['board_token'])) unset($_POST['board_token']);//トークン破棄
トークンは1回使用したらすぐに破棄します。
理由は
トークンを何度も使い回す仕様にすると、トークン自体が盗まれた時点で攻撃が成立するようになってしまうため
です。
合言葉に毎回異なるものを使用することでなりすましを防いでいるのです。
SQLインジェクション
SQLインジェクション対策として追記しているのは以下の★マークの箇所です。
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
PDO::ATTR_EMULATE_PREPARES => false, // ★【SQLインジェクション】静的プレースホルダーを使用
]);
「静的プレースホルダーを使用」と書いてありますが、これを説明するにはまずプレースホルダーを理解していただく必要があります。
プレースホルダーとは、以下の処理の部分のことです。
/**
* 投稿内容登録処理
*/
$sql = ('
INSERT INTO
board_info (title, comment, contributor_id)
VALUES
(:TITLE, :COMMENT, :CONTRIBUTOR_ID)
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
$stmt->bindValue(':CONTRIBUTOR_ID', $cont_id, PDO::PARAM_STR);
注目して頂きたいのはハイライトされている箇所の「:」が付いている変数です。
これがいわゆるプレースホルダーと呼ばれるもので、意味としては「一時的なスペース」です。
なぜこのようなものを使用しているのかというと、プレースホルダーを間に挟むことで不正なSQLの実行を阻止することができるためです。
詳しいご説明は割愛させて頂きますが、このプレースホルダーには静的と動的の2種類があり、静的プレースホルダーを利用すると、SQLの構造が確定するタイミングと実行タイミングの兼ね合いから、理論上SQLインジェクションが成功しなくなるのです。
SQLインジェクションは、後からSQLの構造を無理やり変えることで不正な操作を行います。
しかし、静的プレースホルダーを利用すると、後から構造が変わることが無くなるため、SQLインジェクションが成立しなくなります。
Webアプリケーションの仕様によっては動的プレースホルダーを利用する必要がある場合も出てくるのですが、そうでないのなら静的プレースホルダーを利用するのが確実です。
セッションハイジャック
先ほど、セッションハイジャックの説明の中で、ページ遷移ごとにセッションIDを刷新することが対策になるとお伝えしました。
それを行っているのが以下の箇所です。
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
// ★【セッションハイジャック】セッションIDを新しいものに置き換える
session_regenerate_id();
session_regenerate_id()関数は、セッションIDを新しいものと置き換える関数です。
その際、セッションに元々格納済みのデータは維持されます。
【まとめ】正しいセキュリティ対策で安全なWebアプリケーション開発を
最後に改めて、セキュリティ対策を施した全体のコードを掲載します。
board.php(掲示板トップ)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* 投稿者ID(20桁)を生成
*/
if (isset($_SESSION['cont_id'])) {
$cont_id = $_SESSION['cont_id'];
} else {
$_SESSION['cont_id'] =
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) . chr(mt_rand(65, 90)) .
chr(mt_rand(65, 90)) . chr(mt_rand(65, 90));
$cont_id = $_SESSION['cont_id'];
}
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 投稿ボタンが押下されたときの処理
*/
if (isset($_POST['post_btn'])) {
// 更新操作用の処理
unset($_SESSION['id']);
/**
* セッション変数に情報を保存して
* タイトルまたは投稿内容の片方だけが
* 入力されていた場合、
* 入力フォームに内容を保持する
*/
if (isset($_POST['post_title']) && $_POST['post_title'] != '') {
$_SESSION['title'] = $_POST['post_title'];
} else {
unset($_SESSION['title']);
}
if (isset($_POST['post_comment']) && $_POST['post_comment'] != '') {
$_SESSION['comment'] = $_POST['post_comment'];
} else {
unset($_SESSION['comment']);
}
/**
* エラーメッセージ格納
*/
if ($_POST['post_title'] == '') $err_msg_title = '※タイトルを入力して下さい';
if ($_POST['post_comment'] == '') $err_msg_comment = '※投稿内容を入力して下さい';
/**
* 必要項目がすべて入力されてたら投稿処理を実行
*/
if (
isset($_POST['post_title']) && $_POST['post_title'] != '' &&
isset($_POST['post_comment']) && $_POST['post_comment'] != ''
) {
$title = $_POST['post_title'];
$comment = $_POST['post_comment'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容登録処理
*/
$sql = ('
INSERT INTO
board_info (title, comment, contributor_id)
VALUES
(:TITLE, :COMMENT, :CONTRIBUTOR_ID)
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
$stmt->bindValue(':CONTRIBUTOR_ID', $cont_id, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// 投稿に成功したらセッション変数を破棄
unset($_SESSION['title']);
unset($_SESSION['comment']);
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 投稿一覧取得処理
*/
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
$sql = ('
SELECT *
FROM board_info
ORDER BY id DESC
');
$stmt = $pdo->prepare($sql);
// SQL実行
$stmt->execute();
// 投稿情報を辞書形式ですべて取得
$post_list = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
?>
<!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>
<h1>掲示板アプリ</h1>
<!-- 投稿フォーム -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<label>
<p>タイトル(※最大30文字)</p>
<input type="text" name="post_title" value="<?php if (isset($_SESSION['title'])) echo $_SESSION['title']; ?>">
<!-- エラーメッセージ -->
<?php if (isset($err_msg_title)) {
echo "<p class='err'>{$err_msg_title}</p>";
} ?>
</label>
</div>
<div>
<label>
<p>投稿内容(※最大1000文字)</p>
<textarea name="post_comment" cols="50" rows="10"><?php if (isset($_SESSION['comment'])) echo $_SESSION['comment']; ?></textarea>
<!-- エラーメッセージ -->
<?php if (isset($err_msg_comment)) {
echo "<p class='err'>{$err_msg_comment}</p>";
} ?>
</label>
</div>
</div>
<button class="btn--mg-c" type="submit" name="post_btn" value="post_btn">投稿</button>
</form>
</section>
<hr>
<!-- 投稿一覧 -->
<section class="post-list">
<?php if (count($post_list) === 0) : ?>
<!-- 投稿が無いときはメッセージを表示する -->
<p class="no-post-msg">現在、投稿はありません。</p>
<?php else : ?>
<ul>
<!-- 投稿情報の出力 -->
<?php foreach ($post_list as $post_item) : ?>
<li>
<form action="" method="post">
<!-- 投稿ID -->
<span>ID:<?php echo $post_item['id']; ?> </span>
<!-- 投稿タイトル -->
<span><?php echo $post_item['title']; ?></span>
<!-- 投稿者ID -->
<span>/投稿者:<?php echo $post_item['contributor_id']; ?></span>
<!-- 投稿内容 -->
<p class="p-pre"><?php echo $post_item['comment']; ?></p>
<!-- 投稿日時 -->
<span class="post-datetime">投稿日時:<?php echo $post_item['created_at']; ?></span>
<!-- 過去に更新されていたら更新日時も表示 -->
<?php if ($post_item['created_at'] < $post_item['updated_at']) : ?>
<span class="post-datetime post-datetime__updated">更新日時:<?php echo $post_item['updated_at']; ?></span>
<?php endif; ?>
</form>
<!-- 自分の投稿内容かつセッションが有効な間は編集・削除が可能 -->
<?php if ($post_item['contributor_id'] === $cont_id) : ?>
<div class="btn-flex">
<form action="update-edit.php" method="post">
<button type="submit" name="update_btn">編集</button>
<input type="hidden" name="post_id" value="<?php echo $post_item['id']; ?>">
</form>
<form action="delete-confirm.php" method="post">
<button type="submit" name="delete_btn">削除</button>
<input type="hidden" name="post_id" value="<?php echo $post_item['id']; ?>">
</form>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['id']) && ($_SESSION['id'] == $post_item['id'])): ?>
<p class='updated-post'>更新しました</p>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
</body>
</html>
update-edit.php(編集画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 編集ボタンで遷移してきたときの処理
*/
if (isset($_POST['update_btn'])) {
/**
* 編集対象の投稿情報を取得
*/
if (isset($_POST['post_id']) && $_POST['post_id'] != '') {
// セッションに投稿IDを保持
$_SESSION['id'] = $_POST['post_id'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容取得処理
*/
$sql = ('
SELECT id, title, comment
FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 投稿情報の取得
$post_info = $stmt->fetch();
$_SESSION['title'] = $post_info['title'];
$_SESSION['comment'] = $post_info['comment'];
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 更新ボタンが押下されたときの処理
*/
if (isset($_POST['update_submit_btn'])) {
/**
* セッション変数に情報を保存して
* タイトルまたは投稿内容の片方だけが
* 入力されていた場合、
* 入力フォームに内容を保持する
*/
if (isset($_POST['post_title']) && $_POST['post_title'] != '') {
$_SESSION['title'] = $_POST['post_title'];
} else {
unset($_SESSION['title']);
}
if (isset($_POST['post_comment']) && $_POST['post_comment'] != '') {
$_SESSION['comment'] = $_POST['post_comment'];
} else {
unset($_SESSION['comment']);
}
/**
* エラーメッセージ格納
*/
if ($_POST['post_title'] == '') $err_msg_title = '※タイトルを入力して下さい';
if ($_POST['post_comment'] == '') $err_msg_comment = '※投稿内容を入力して下さい';
/**
* 必要項目がすべて入力されてたら更新処理を実行
*/
if (
isset($_POST['post_title']) && $_POST['post_title'] != '' &&
isset($_POST['post_comment']) && $_POST['post_comment'] != ''
) {
$title = $_POST['post_title'];
$comment = $_POST['post_comment'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // データをカラム名をキーとする連想配列で取得する
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容更新処理
*/
$sql = ('
UPDATE board_info
SET title = :TITLE, comment = :COMMENT
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
$stmt->bindValue(':TITLE', $title, PDO::PARAM_STR);
$stmt->bindValue(':COMMENT', $comment, PDO::PARAM_STR);
// SQL実行
$stmt->execute();
// 更新に成功したらセッション変数を破棄
// unset($_SESSION['id']); // ※投稿IDは敢えて破棄せず、掲示板ページでID判定をするために情報を保持する★
unset($_SESSION['title']);
unset($_SESSION['comment']);
// 掲示板ページへ戻る
header('Location: board.php');
exit();
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* キャンセルボタンが押下されたら
* セッション情報を破棄して
* 掲示板一覧画面へ戻る
*/
if (isset($_POST['cancel_btn'])) {
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
header('Location: board.php');
exit();
}
?>
<!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>
<h1>投稿編集画面</h1>
<!-- 投稿編集フォーム -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<label>
<p>タイトル</p>
<input type="text" name="post_title" value="<?php if (isset($_SESSION['title'])) echo $_SESSION['title']; ?>">
<!-- エラーメッセージ -->
<?php if (isset($err_msg_title)) {
echo "<p class='err'>{$err_msg_title}</p>";
} ?>
</label>
</div>
<div>
<label>
<p>投稿内容</p>
<textarea name="post_comment" cols="50" rows="10"><?php if (isset($_SESSION['comment'])) echo $_SESSION['comment']; ?></textarea>
<!-- エラーメッセージ -->
<?php if (isset($err_msg_comment)) echo "<p class='err'>{$err_msg_comment}</p>"; ?>
</label>
</div>
</div>
<div class="btn-flex">
<button type="submit" name="update_submit_btn" value="update_submit_btn">更新</button>
<button type="submit" name="cancel_btn" value="cancel_btn">キャンセル</button>
</div>
</form>
</section>
</body>
</html>
delete-confirm.php(削除確認画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* DB接続情報
*/
const DB_HOST = 'mysql:dbname=board;host=127.0.0.1;charset=utf8';
const DB_USER = 'kekenta';
const DB_PASSWORD = 'kekenta_pass';
/**
* 削除ボタンで遷移してきたときの処理
*/
if (isset($_POST['delete_btn'])) {
/**
* 編集対象の投稿情報を取得
*/
if (isset($_POST['post_id']) && $_POST['post_id'] != '') {
// セッションに投稿IDを保持
$_SESSION['id'] = $_POST['post_id'];
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容登録処理
*/
$sql = ('
SELECT id, title, comment
FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 投稿情報の取得
$post_info = $stmt->fetch();
$_SESSION['title'] = $post_info['title'];
$_SESSION['comment'] = $post_info['comment'];
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
}
/**
* 削除ボタンが押下されたときの処理
*/
if (isset($_POST['delete_submit_btn'])) {
try {
/**
* DB接続処理
*/
$pdo = new PDO(DB_HOST, DB_USER, DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 例外が発生した際にスローする
]);
/**
* 投稿内容削除処理
*/
$sql = ('
DELETE FROM board_info
WHERE id = :ID
');
$stmt = $pdo->prepare($sql);
// プレースホルダーに値をセット
$stmt->bindValue(':ID', $_SESSION['id'], PDO::PARAM_INT);
// SQL実行
$stmt->execute();
// 削除に成功したらセッション変数を破棄
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
// 削除成功画面へ遷移
header('Location: delete-success.php');
exit();
} catch (PDOException $e) {
echo '接続失敗' . $e->getMessage();
exit();
}
// DBとの接続を切る
$pdo = null;
$stmt = null;
}
/**
* キャンセルボタンが押下されたら
* セッション情報を破棄して
* 掲示板一覧画面へ戻る
*/
if (isset($_POST['cancel_btn'])) {
unset($_SESSION['id']);
unset($_SESSION['title']);
unset($_SESSION['comment']);
header('Location: board.php');
return;
}
?>
<!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>
<h1>削除確認</h1>
<p class="delete-confirm-msg">以下の投稿を削除します。</p>
<!-- 削除確認画面 -->
<section class="post-form">
<form action="#" method="post">
<div class="post-form__flex">
<div>
<p>タイトル</p>
<p><?php if (isset($_SESSION['title'])) echo $_SESSION['title']; ?></p>
</div>
<div>
<p>投稿内容</p>
<p class="p-pre"><?php if (isset($_SESSION['comment'])) echo $_SESSION['comment']; ?></p>
</div>
</div>
<div class="btn-flex">
<button type="submit" name="delete_submit_btn" value="delete_submit_btn">削除</button>
<button type="submit" name="cancel_btn" value="cancel_btn">キャンセル</button>
</div>
</form>
</section>
</body>
</html>
delete-success.php(削除成功画面)
<?php
/**
* セッション開始
* セッションの保存期間を1800秒に指定 ※任意の秒数へ変更可能
* かつ、確実に破棄する
*/
ini_set('session.gc_maxlifetime', 1800);
ini_set('session.gc_divisor', 1);
session_start();
/**
* 掲示場TOPへ自動で遷移する処理★
*/
header('refresh: 3; url=board.php');
?>
<!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>
<h1>削除が完了しました。</h1>
<p class="delete-success-msg">3秒後に自動で掲示板TOPへ戻ります。</p>
</body>
</html>
今回は前回こちらの記事で解説をした掲示板アプリにおけるセキュリティ対策について解説をしてきました。
しかし、実を言うと
セキュリティ対策に100%は無く、できるだけ防御率を100%に近づけるという考え方が正しい
です。
また、アプリの仕様によって取るべき対策は異なります。そのため、セキュリティ対策の知識はある意味プログラミング言語の学習よりも重要で大変なものだったりします。
僕は以下の書籍(通称「徳丸本」と呼ばれるWeb開発者必携の書籍です)を参考に、いつもWebアプリ開発をしています。
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 脆弱性が生まれる原理と対策の実践
徳丸 浩/著 SBクリエイティブ/出版│Amazon
それでも本当に対策ができているのか不安なので、色々なネット記事も参考にしながら開発をしています。
セキュリティ攻撃は残念なことに日々新しい手法が誕生していっている分野なので、開発する側もしっかりと学習を継続することが大切です。
それでは本記事は以上とさせていただきます。
最後までお読みいただきありがとうございました!