SQLインジェクションとセキュリティ

SQLインジェクションとセキュリティ

🎯 たとえ話

宅配ピックアップステーションを想像してください:

📚 コアコンセプト

1. SQLインジェクションの原理

SQLインジェクションの本質は:ユーザー入力がSQLコードとして実行されることです。

プログラムが文字列連結でSQLを構築する際、攻撃者は悪意のあるSQLフラグメントを挿入できます。

SQL
-- バックエンドコード(擬似コード)
sql = "SELECT * FROM users WHERE username = '" + input_user + "' AND password = '" + input_pass + "'"

-- 正常な入力
-- input_user = "admin"
-- input_pass = "123456"
-- 生成されるSQL:
SELECT * FROM users WHERE username = 'admin' AND password = '123456'

-- 悪意のある入力
-- input_user = "admin' --"
-- input_pass = "anything"
-- 生成されるSQL:
SELECT * FROM users WHERE username = 'admin' --' AND password = 'anything'
-- '--'はSQLコメントであり、残りの条件はコメントアウトされ、パスワード検証がバイパスされます!

2. 一般的なインジェクション攻撃手法

SQL
-- 1. ログイン検証のバイパス
-- 入力: admin' OR '1'='1' --
SELECT * FROM users WHERE username = 'admin' OR '1'='1' --' AND password = ''

-- 2. UNIONクエリによるデータ窃取
-- 入力: ' UNION SELECT username, password FROM users --
SELECT * FROM products WHERE name = '' UNION SELECT username, password FROM users --'

-- 3. スタックドクエリによる複数文の実行
-- 入力: '; DROP TABLE users; --
SELECT * FROM products WHERE name = ''; DROP TABLE users; --'

-- 4. ブラインドインジェクション(レスポンスの違いからデータを推測)
-- 入力: ' AND (SELECT SUBSTRING(username,1,1) FROM users LIMIT 1)='a' --
SELECT * FROM products WHERE name = '' AND (SELECT SUBSTRING(username,1,1) FROM users LIMIT 1)='a' --'

3. パラメータ化クエリ(Prepared Statement)

コア思想:SQL構造とデータを分離し、データベースエンジンがパラメータ値をSQLコードとして実行しないようにする。

SQL
-- ❌ 文字列連結(危険)
SET @username = "admin' --";
SET @sql = CONCAT('SELECT * FROM users WHERE username = ''', @username, '''');
PREPARE stmt FROM @sql;
EXECUTE stmt;

-- ✅ パラメータ化クエリ(安全)
SET @username = "admin' --";
PREPARE stmt FROM 'SELECT * FROM users WHERE username = ?';
EXECUTE stmt USING @username;
-- データベースは@username全体を単一の文字列値として扱い、内部のSQLフラグメントは実行されない

各言語でのパラメータ化クエリ:

PYTHON
# Python + MySQL Connector
cursor.execute("SELECT * FROM users WHERE username = %s AND password = %s", (username, password))

# Python + SQLAlchemy ORM
user = session.query(User).filter(User.username == username, User.password == password).first()
JAVASCRIPT
// Node.js + mysql2
const [rows] = await pool.execute('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]);

// Node.js + Prisma ORM
const user = await prisma.user.findFirst({ where: { username, password } });
JAVA
// Java JDBC
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE username = ? AND password = ?");
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();
PHP
// PHP PDO
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->execute(['username' => $username, 'password' => $password]);

4. ORMによるインジェクション防止

ORM(Object-Relational Mapping)フレームワークは通常、自動的にパラメータ化クエリを使用しますが、生のSQLを連結する際には注意が必要です:

PYTHON
# ✅ ORMの自動パラメータ化(安全)
User.objects.filter(username=username, password=password)

# ❌ ORMで生のSQLを連結(危険)
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")

# ✅ ORMで生のSQLを安全に使用
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])
JAVASCRIPT
// ✅ Sequelize ORMの自動パラメータ化
await User.findOne({ where: { username, password } });

// ❌ Sequelizeの連結クエリ(危険)
await sequelize.query(`SELECT * FROM users WHERE username = '${username}'`);

// ✅ Sequelizeの安全な方法
await sequelize.query("SELECT * FROM users WHERE username = ?", { replacements: [username] });

5. 最小権限の原則

データベースユーザーには、タスクを完了するために必要な最小限の権限のみを付与すべきです:

SQL
-- 読み取り専用ユーザーの作成
CREATE USER 'app_readonly'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT ON mydb.* TO 'app_readonly'@'%';

-- アプリケーションユーザーの作成(CRUDのみ許可、DROP/ALTERは不可)
CREATE USER 'app_user'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'app_user'@'%';

-- 権限の伝播を防止
REVOKE GRANT OPTION ON mydb.* FROM 'app_user'@'%';

-- 管理者ユーザー(内部管理のみ)
CREATE USER 'db_admin'@'localhost' IDENTIFIED BY 'very_strong_password';
GRANT ALL PRIVILEGES ON mydb.* TO 'db_admin'@'localhost';

6. 防御チェックリスト

防御策 説明
✅ パラメータ化クエリ すべてのSQLクエリにパラメータプレースホルダーを使用
✅ ORMフレームワーク 手書きSQLを減らすためにORMを優先使用
✅ 入力検証 長さ、型、形式を検証(ブラックリストよりホワイトリスト)
✅ 最小権限 データベースユーザーに必要な権限のみ付与
✅ エラーメッセージ データベースのエラー詳細をユーザーに公開しない
✅ WAF保護 Web Application Firewallを導入
✅ 定期監査 コードのSQL連結をチェック
❌ 文字列エスケープ 単独の防御策としては信頼性が不十分

💡 基本構文

SQL
-- MySQL Prepared Statement
PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?';
SET @id = 1;
EXECUTE stmt USING @id;
DEALLOCATE PREPARE stmt;

-- MySQL LIKEクエリの安全な方法
PREPARE stmt FROM 'SELECT * FROM users WHERE name LIKE ?';
SET @pattern = CONCAT('%', 'keyword', '%');
EXECUTE stmt USING @pattern;

-- IN句の安全な方法(動的構築が必要)
-- 方法:FIND_IN_SETまたは一時テーブルを使用
PREPARE stmt FROM 'SELECT * FROM users WHERE FIND_IN_SET(id, ?)';
SET @ids = '1,2,3';
EXECUTE stmt USING @ids;
💡 ヒント:ユーザー入力を決して信頼しないでください。ドロップダウンメニューや隠しフィールドでさえ改竄される可能性があり、サーバー側ですべての入力を検証し、パラメータ化する必要があります。

例:ログインエンドポイントのインジェクション対策(難易度⭐)

修正前(危険):

PYTHON
# Python Flask - 危険な方法
@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']
    # ❌ 文字列連結、SQLインジェクションの脆弱性あり
    sql = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
    user = db.execute(sql).fetchone()
    if user:
        return "Login successful"
    return "Invalid username or password"
▶ 試してみよう

修正後(安全):

PYTHON
# Python Flask - 安全な方法
@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']

    # 入力検証
    if not username or len(username) > 50:
        return "Invalid username format", 400
    if not password or len(password) > 100:
        return "Invalid password format", 400

    # ✅ パラメータ化クエリ
    user = db.execute(
        "SELECT * FROM users WHERE username = ? AND password = ?",
        (username, password)
    ).fetchone()

    if user:
        return "Login successful"
    return "Invalid username or password"

例:インジェクション防止とパフォーマンス最適化を兼ねた検索機能(難易度⭐⭐)

危険な方法:

JAVASCRIPT
// Node.js Express - 危険な方法
app.get('/search', (req, res) => {
    const keyword = req.query.q;
    // ❌ SQL連結、インジェクションの脆弱性あり
    const sql = `SELECT * FROM articles WHERE title LIKE '%${keyword}%' OR content LIKE '%${keyword}%'`;
    db.query(sql, (err, results) => {
        res.json(results);
    });
});
▶ 試してみよう

安全+最適化された方法:

JAVASCRIPT
// Node.js Express - 安全+最適化された方法
app.get('/search', async (req, res) => {
    try {
        const keyword = req.query.q?.trim();

        // 入力検証
        if (!keyword || keyword.length > 100) {
            return res.status(400).json({ error: '検索キーワードは1~100文字で入力してください' });
        }

        // ✅ パラメータ化クエリ + 全文インデックス最適化
        const sql = `
            SELECT id, title, LEFT(content, 200) AS snippet, created_at
            FROM articles
            WHERE status = 1
              AND MATCH(title, content) AGAINST(? IN BOOLEAN MODE)
            ORDER BY MATCH(title, content) AGAINST(? IN BOOLEAN MODE) DESC
            LIMIT 20
        `;
        const [rows] = await pool.execute(sql, [keyword, keyword]);
        res.json(rows);
    } catch (err) {
        // データベースのエラー詳細を公開しない
        console.error('Search error:', err);
        res.status(500).json({ error: '検索サービスは一時的に利用できません' });
    }
});

🏢 シナリオ1:ユーザー登録エンドポイントのセキュリティ

PYTHON
# Python - 安全なユーザー登録
import re
import hashlib

@app.route('/register', methods=['POST'])
def register():
    username = request.form.get('username', '').strip()
    email = request.form.get('email', '').strip()
    password = request.form.get('password', '')

    # 1. 入力検証(ホワイトリスト)
    if not re.match(r'^[a-zA-Z0-9_]{3,50}$', username):
        return "Username can only contain letters, numbers, and underscores, 3-50 characters", 400
    if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email) or len(email) > 100:
        return "Invalid email format", 400
    if len(password) < 8 or len(password) > 100:
        return "Password must be 8-100 characters", 400

    # 2. パラメータ化クエリで存在確認
    existing = db.execute(
        "SELECT id FROM users WHERE username = ? OR email = ?",
        (username, email)
    ).fetchone()
    if existing:
        return "Username or email already registered", 409

    # 3. パスワードハッシュ保存
    password_hash = hashlib.sha256(password.encode()).hexdigest()

    # 4. パラメータ化挿入
    db.execute(
        "INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)",
        (username, email, password_hash)
    )
    db.commit()

    return "Registration successful", 201

🏢 シナリオ2:動的フィルタ条件の安全な構築

JAVASCRIPT
// Node.js - 安全な動的クエリ構築
function buildProductQuery(filters) {
    let sql = 'SELECT * FROM products WHERE 1=1';
    const params = [];

    if (filters.categoryId) {
        sql += ' AND category_id = ?';
        params.push(filters.categoryId);
    }

    if (filters.minPrice !== undefined) {
        sql += ' AND price >= ?';
        params.push(filters.minPrice);
    }

    if (filters.maxPrice !== undefined) {
        sql += ' AND price <= ?';
        params.push(filters.maxPrice);
    }

    if (filters.keyword) {
        sql += ' AND name LIKE ?';
        params.push(`%${filters.keyword}%`);
    }

    if (filters.status !== undefined) {
        sql += ' AND status = ?';
        params.push(filters.status);
    }

    // ソートフィールドのホワイトリスト
    const allowedSort = ['price', 'created_at', 'sales_count'];
    const sortBy = allowedSort.includes(filters.sortBy) ? filters.sortBy : 'created_at';
    const sortOrder = filters.sortOrder === 'ASC' ? 'ASC' : 'DESC';
    sql += ` ORDER BY ${sortBy} ${sortOrder}`;

    // ページネーションパラメータの検証
    const page = Math.max(1, parseInt(filters.page) || 1);
    const pageSize = Math.min(100, Math.max(1, parseInt(filters.pageSize) || 20));
    sql += ' LIMIT ? OFFSET ?';
    params.push(pageSize, (page - 1) * pageSize);

    return { sql, params };
}

// 使用例
app.get('/products', async (req, res) => {
    const { sql, params } = buildProductQuery(req.query);
    const [rows] = await pool.execute(sql, params);
    res.json(rows);
});
💡 ポイント:ソートフィールドはパラメータ化プレースホルダーを直接使用できません(列名であり値ではないため)。ホワイトリストで検証する必要があります。

❓ よくある質問

質問:パラメータ化クエリはパフォーマンスに影響しますか? 回答: いいえ。パラメータ化クエリはデータベースの事前コンパイルキャッシュも活用でき、同じクエリテンプレートの繰り返し実行が高速になります。

質問:ORMフレームワークは常に安全ですか? 回答: いいえ。ORMの自動パラメータ化は安全ですが、raw() や文字列連結でクエリを構築した場合、インジェクションリスクが残ります。フレームワークは単なるツールであり、重要なのはセキュリティ意識です。

質問:ストアドプロシージャはインジェクションを防げますか? 回答: ストアドプロシージャ自体はインジェクションを防ぎません。ストアドプロシージャ内部で動的SQL連結を使用している場合、同様に脆弱です。重要なのはパラメータ化を使用しているかどうかです。

質問:フロントエンドの検証だけで十分ですか? 回答: 絶対にそうではありません。フロントエンドの検証はバイパスできます(APIを直接呼び出す、リクエストを変更するなど)。すべてのセキュリティ検証はサーバー側で行う必要があります。

📖 まとめ

このレッスンでは、SQLインジェクション攻撃と防御を深く学びました:

📝 演習

  1. 次のコードのSQLインジェクション脆弱性を見つけて修正してください:
    SQL
    query = "SELECT * FROM products WHERE category = '" + category + "' ORDER BY " + sortField
    
  2. ユーザー検索機能(あいまい検索、ページネーション、ソート対応)に対する安全なパラメータ化クエリコードを記述してください。
  3. あなたのプロジェクト用に3つのデータベースユーザー(読み取り専用ユーザー、アプリケーションユーザー、管理者ユーザー)を作成し、それぞれに適切な権限を付与してください。

次のレッスン →27-optimization.md

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%