Webアプリケーションでは、ユーザー情報、検索条件、コンテンツデータなど、多くの情報をデータベースに保存します。
そのため、データベースへのアクセス方法に問題があると、重大なセキュリティリスクにつながる可能性があります。
代表的な攻撃の一つが SQL Injection です。
本記事では、SQL Injectionがどのように発生するのか、そして安全なWebアプリケーションを構築するためにどのような対策を取るべきかを解説します。
SQL Injectionとは?
SQL Injectionは、ユーザー入力を利用して意図しないSQLを実行させる攻撃手法です。
例えば、次のようなコードがあるとします。
const query =
"SELECT * FROM users WHERE name = '" +
userInput +
"'";
ユーザー入力をそのままSQL文へ埋め込んでいるため、安全とは言えません。
もし入力内容にSQLの構文が含まれていた場合、本来想定していないクエリが実行される可能性があります。
なぜ文字列結合が危険なのか
問題の本質は、「データ」と「SQL文」を同じ文字列として扱っていることです。
安全でない例:
const sql =
`SELECT * FROM users WHERE email = '${email}'`;
この方法では、emailの内容次第でSQL構造が変化する可能性があります。
そのため、ユーザー入力を直接SQLへ結合する設計は避ける必要があります。
Prepared Statementを利用する
基本的な対策は Prepared Statement を利用することです。
例えばNode.jsとMySQLの場合:
const [rows] = await db.execute(
"SELECT * FROM users WHERE email = ?",
[email]
);
この場合、
SQL
↓
SELECT * FROM users WHERE email = ?
Data
↓
user@example.com
のように、SQL構造とデータを分離して扱えます。
これにより、ユーザー入力がSQLコードとして解釈されることを防ぎやすくなります。
Pythonの場合
Pythonでも同じ考え方です。
SQLiteの例:
cursor.execute(
"SELECT * FROM users WHERE email = ?",
(email,)
)
危険な書き方:
query = f"""
SELECT * FROM users
WHERE email = '{email}'
"""
SQL文字列を手動で組み立てるのではなく、必ずパラメータバインディングを利用することが重要です。
ORMを利用する
ORM(Object Relational Mapping)を利用すると、SQLを直接書く機会を減らすことができます。
例えばPrismaでは、
const user = await prisma.user.findUnique({
where: {
email: email
}
});
のようにデータへアクセスできます。
ORMは内部でパラメータ処理を行うため、SQL Injection対策としても有効です。
ただし、
$executeRawUnsafe()
のようなRaw SQL機能を利用する場合は注意が必要です。
ORMを使っているから絶対安全というわけではありません。
入力値のValidation
Prepared Statementだけでなく、入力値のValidationも重要です。
例えばユーザーIDが数値の場合:
const userId = Number(req.params.id);
if (!Number.isInteger(userId)) {
return res.status(400).json({
error: "Invalid user ID"
});
}
メールアドレスの場合も、
- 最大文字数
- フォーマット
- 許可文字
などを確認します。
ValidationはSQL Injection対策だけでなく、アプリケーション品質の向上にも役立ちます。
「エスケープだけ」に依存しない
ユーザー入力をエスケープする方法もありますが、それだけに依存するのはおすすめできません。
データベースや文字コード、SQL構文によって挙動が異なる場合があります。
基本方針としては、
Prepared Statement
+
Validation
+
Least Privilege
を組み合わせる方が安全です。
データベース権限を最小限にする
アプリケーションからデータベースへ接続するユーザーには、必要最低限の権限だけを与えます。
例えば読み取り専用サービスなら、
SELECT
だけで十分な場合があります。
不要な、
DROP
ALTER
CREATE
などの権限を持たせないことで、問題が発生した場合の影響範囲を小さくできます。
これは Least Privilege(最小権限) の考え方です。
エラー内容をそのまま表示しない
開発中はSQLエラーを画面へ表示すると便利ですが、本番環境では危険です。
例えば、
SQL syntax error near ...
Table users does not exist
Database name: production_db
のような情報がユーザーへ表示されると、内部構造を推測される可能性があります。
本番環境では、
{
"error": "Internal server error"
}
のような一般的なメッセージを返し、詳細はサーバーログへ記録します。
ログにも注意する
セキュリティログは重要ですが、入力値をすべてそのまま保存すると別の問題が発生することがあります。
例えば、
- パスワード
- Access Token
- Session ID
- 個人情報
などはログへ保存しないようにします。
必要な情報だけを記録する設計が重要です。
WAFは補助的に利用する
Web Application Firewall(WAF)を利用すると、不審なリクエストを検知・ブロックできる場合があります。
しかし、WAFだけでSQL Injection対策を完結させることはできません。
優先順位としては、
安全なコード
↓
入力Validation
↓
DB権限制御
↓
WAF
と考える方がよいでしょう。
WAFは最後の防御レイヤーとして利用します。
テストを自動化する
SQL Injection対策は、コードレビューだけでなくテストでも確認できます。
例えばAPIテストでは、
test("invalid input returns 400", async () => {
const response = await request(app)
.get("/users/not-a-number");
expect(response.status).toBe(400);
});
のように、想定外入力への挙動を確認できます。
さらに、
- SAST
- Dependency Scan
- Security Test
などをCI/CDへ組み込むことで、問題を早い段階で発見しやすくなります。
デジタルプラットフォームで考えるデータベースセキュリティ
ユーザーアカウントやコンテンツ情報を扱うデジタルサービスでは、データベースセキュリティは非常に重要です。
例えば、JLPH のようなオンラインデジタルプラットフォームを技術的なケースとして考える場合でも、検索フォーム、アカウント管理、コンテンツ取得など、多くの処理でデータベースアクセスが発生します。
そのため、
- Prepared Statement
- 入力Validation
- 最小権限
- 適切なLogging
- Error Handling
をアプリケーション全体で統一することが重要です。
特定のページだけ安全にするのではなく、データアクセス層全体でルールを統一することで、保守性と安全性の両方を高められます。
セキュリティチェックリスト
SQLを扱うコードでは、以下を確認すると便利です。
□ ユーザー入力を直接SQLへ連結していない
□ Prepared Statementを利用している
□ 入力値をValidationしている
□ DBユーザーの権限が最小限になっている
□ SQLエラーを外部へ表示していない
□ 機密情報をログへ保存していない
□ ORMのRaw SQL利用箇所を確認している
□ 依存ライブラリを定期的に更新している
このようなチェックリストをPull Requestのレビュー項目に含める方法も有効です。
まとめ
SQL Injectionは古くから知られている攻撃ですが、現在のWeb開発でも基本的な対策は重要です。
特に覚えておきたいのは、
- SQLを文字列結合で作らない
- Prepared Statementを利用する
- 入力値をValidationする
- データベース権限を最小化する
- エラー情報を外部へ公開しない
- ORMのRaw SQLにも注意する
という点です。
セキュリティは特別な機能として後から追加するものではありません。
普段のデータベースアクセス方法そのものを安全に設計することで、Webアプリケーション全体のリスクを継続的に下げることができます。