セキュリティリスク
データベースには機微なデータが入っていることが多く、危険な操作も行えます。Nette Database を安全に使うには、次のことが欠かせません。
- 安全な API と危険な API の違いを理解する
- パラメータ化されたクエリを使う
- 入力データをきちんと検証する
SQL インジェクションとは何か
SQL インジェクションはデータベースを扱ううえでもっとも深刻なセキュリティリスクです。これは検査されていないユーザーの入力が SQL のクエリの一部になったときに起こります。攻撃者は自分の SQL のコマンドを差し込んで、次のことができてしまいます。
- データへの許されないアクセスを得る
- データベースのデータを書き換えたり消したりする
- 認証をすり抜ける
// ❌ 危険なコード - SQL インジェクションに対して脆弱です
$database->query("SELECT * FROM users WHERE name = '$_GET[name]'");
// 攻撃者は次のような値を入れるかもしれません: ' OR '1'='1
// できあがるクエリは: SELECT * FROM users WHERE name = '' OR '1'='1'
// これはすべてのユーザーを返します
同じことが Database Explorer にも当てはまります。
// ❌ 危険なコード - SQL インジェクションに対して脆弱です
$table->where('name = ' . $_GET['name']);
$table->where("name = '$_GET[name]'");
パラメータ化されたクエリ
SQL インジェクションに対する基本の守りはパラメータ化されたクエリです。Nette Database はその使い方をいくつか用意しています。
もっとも単純なのは疑問符のプレースホルダを使うことです。
// ✅ 安全なパラメータ化されたクエリ
$database->query('SELECT * FROM users WHERE name = ?', $name);
// ✅ Explorer での安全な条件
$table->where('name = ?', $name);
これは、疑問符のプレースホルダとパラメータで式を入れられる Database Explorerのほかのすべてのメソッドにも当てはまります。
INSERT、UPDATE のコマンドや WHERE 句では、値を配列で渡せます。
// ✅ 安全な INSERT
$database->query('INSERT INTO users', [
'name' => $name,
'email' => $email,
]);
// ✅ Explorer での安全な INSERT
$table->insert([
'name' => $name,
'email' => $email,
]);
パラメータの値の検証
パラメータ化されたクエリは、安全にデータベースを扱うための土台です。とはいえ、そこに入れる値はいくつかの段階の検査を経なければなりません。
型の検査
もっとも大事なのはパラメータのデータ型が正しいことを確かめることです。これは Nette Database を安全に使うために欠かせない条件です。データベースは、入力データがすべてその列に対応する正しいデータ型を持っていると前提にしています。
たとえば前の例の $name が思いがけず文字列ではなく配列だったとすると、Nette Database
はその要素をすべて SQL のクエリに入れようとして、エラーになります。ですから
$_GET、$_POST、$_COOKIE
からの検証していないデータを、データベースのクエリで直接決して使わないでください。
書式の検証
2 つめの段階ではデータの書式を確かめます。たとえば文字列が UTF-8 の文字コードで、長さが列の定義に合っているか、数値がその列のデータ型で許される範囲に収まっているか、といったことです。
この段階の検証はデータベース自身にある程度は任せられます。多くのデータベースは正しくないデータを拒みます。とはいえ振る舞いはさまざまで、長い文字列を黙って切り詰めたり、範囲の外の数を丸めたりするものもあります。
ドメインに固有の検証
3 つめの段階は、あなたのアプリケーションに固有の論理的な検査です。たとえば選択肢の値が提示されたものと一致するか、数が思ったとおりの範囲にあるか(年齢 0〜150 歳など)、値どうしの依存関係が筋の通ったものかを確かめます。
おすすめの検証の方法
- すべての入力の適切な検証を自動的に受け持つ Nette Formsを使います。
- プレゼンターを使い、
action*()とrender*()メソッドのパラメータにデータ型を指定します。 - あるいは
filter_var()のような PHP の標準の道具で独自の検証の層を作ります。
列を安全に扱う
前の節では、パラメータの値をきちんと検証する方法を見ました。しかし SQL のクエリで配列を使うときは、そのキーにも同じだけ気を配らなければなりません。
// ❌ 危険なコード - 配列のキーが検査されていません
$database->query('INSERT INTO users', $_POST);
INSERT と UPDATE
のコマンドでは、これは致命的なセキュリティの欠陥です。攻撃者はデータベースのどの列でも挿入したり書き換えたりできてしまいます。たとえば
is_admin = 1 を設定したり、機微な列に好きなデータを入れたりできます(いわゆる Mass
Assignment Vulnerability)。
WHERE の条件ではさらに危険です。演算子を含められるからです。
// ❌ 危険なコード - 配列のキーが検査されていません
$_POST['salary >'] = 100000;
$database->query('SELECT * FROM users WHERE', $_POST);
// クエリ WHERE (`salary` > 100000) が実行されます
攻撃者はこのやり方で従業員の給与を順に探り当てられます。たとえばまず 100,000 より上の給与を問い合わせ、次に 50,000 より下を問い合わせ、範囲を少しずつ狭めていくことで、全員のおおよその給与を暴けます。この種の攻撃を SQL 列挙と呼びます。
where() と whereOr() メソッドはさらにずっと柔軟で、キーと値の中で演算子や関数を含む
SQL の式に対応しています。これは攻撃者に SQL インジェクションの余地を与えます。
// ❌ 危険なコード - 攻撃者が自分の SQL を差し込めます
$_POST = ['0) UNION SELECT name, salary FROM users WHERE (1'];
$table->where($_POST);
// クエリ WHERE (0) UNION SELECT name, salary FROM users WHERE (1) が実行されます
この攻撃は 0) でもとの条件を終わらせ、UNION で自分の SELECT
を継ぎ足して users テーブルの機微なデータを得て、WHERE (1)
で構文的に正しいクエリとして閉じています。
列のホワイトリスト
列の名前を安全に扱うには、ユーザーが許された列だけを扱えて、自分で列を足せないようにするしくみが要ります。危険な列の名前を見つけて遮る(ブラックリスト)こともできますが、この手はあてになりません。攻撃者はこちらが思いつかなかった、危険な列の名前の新しい書き方をいつでも編み出せるからです。
ですから発想を逆にして、許される列のはっきりした一覧(ホワイトリスト)を定義するほうがずっと安全です。
// ユーザーが変えてよい列
$allowedColumns = ['name', 'email', 'active'];
// 入力から許されていない列をすべて取り除きます
$filteredData = array_intersect_key($userData, array_flip($allowedColumns));
// ✅ これでクエリで安全に使えます。たとえば次のようにです:
$database->query('INSERT INTO users', $filteredData);
$table->update($filteredData);
$table->where($filteredData);
動的な識別子
テーブルや列の名前を動的に扱うには ?name
のプレースホルダを使います。これはそのデータベースの構文に従って識別子をきちんとエスケープします(MySQL
ならバッククォートを使います)。
// ✅ 信頼できる識別子の安全な使い方
$table = 'users';
$column = 'name';
$database->query('SELECT ?name FROM ?name', $column, $table);
// MySQL での結果: SELECT `name` FROM `users`
大事な点として、?name
の記号はアプリケーションのコードで定義した信頼できる値にだけ使ってください。ユーザーから来る値には、やはりホワイトリストを使います。さもないとセキュリティリスクにさらされます。
// ❌ 危険 - ユーザーの入力を決して使わないでください
$database->query('SELECT ?name FROM users', $_GET['column']);