1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】SQLインジェクション対策の基本:プレースホルダーを正しく使う

Posted at

はじめに

SQLインジェクションは、Webアプリケーションにおける最も危険な脆弱性の一つです。攻撃者が不正なSQL文を注入することで、データベースの情報漏洩や改ざん、削除を引き起こします。

この脆弱性を防ぐ最も確実な方法がプレースホルダーの使用です。プレースホルダーを使うことで、ユーザー入力をSQL文の一部として解釈させず、純粋なデータとして扱うことができます。

本記事では、SQLインジェクションの危険性とプレースホルダーによる対策方法について、Pythonの実装例を交えて解説します。

SQLインジェクションの危険性

具体的な攻撃例

ユーザーがログインフォームに以下のような入力をしたとします。

ユーザー名: admin' OR '1'='1
パスワード: (任意の文字列)

脆弱なコードでは、この入力が次のようなSQL文を生成してしまいます。

SELECT * FROM users WHERE username='admin' OR '1'='1' AND password='...'

'1'='1'は常に真となるため、パスワードの検証を回避して不正ログインが成立してしまいます。

脆弱なコードの例

文字列連結でSQL文を組み立てるコードは危険です。

# 危険なコード例
username = request.form['username']
password = request.form['password']

query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
cursor.execute(query)

このコードでは、ユーザー入力がそのままSQL文に埋め込まれるため、SQLインジェクションの脆弱性が生まれます。

被害の実例

SQLインジェクション攻撃による被害には以下のようなものがあります。

  • 情報漏洩: 顧客情報、個人情報、機密データの流出
  • データ改ざん: 価格情報や在庫情報の不正な変更
  • データ削除: データベース全体の削除
  • 権限昇格: 管理者権限の不正取得
  • システム侵害: データベースサーバーの乗っ取り

実際に、SQLインジェクションを悪用した大規模な情報漏洩事件が世界中で発生しています。

プレースホルダーによる対策

プレースホルダーの仕組み

プレースホルダーは、SQL文の中でデータが入る位置を「?」や「%s」などの記号で示し、実際のデータは別に渡す方法です。

プリペアドステートメントとの関係

プレースホルダーは、プリペアドステートメント(準備済み文)という技術と組み合わせて使用されます。

プリペアドステートメントでは、SQL文の構造とデータが明確に分離されます。データベースエンジンは、まずSQL文の構造を解析してから、後からデータをバインドするため、データがSQL文として解釈されることはありません。

なぜ安全なのか

プレースホルダーが安全な理由は以下の通りです。

  • SQL文とデータの完全な分離: ユーザー入力がSQL文の構造に影響を与えない
  • 自動エスケープ: データベースドライバが適切にエスケープ処理を行う
  • 型の明確化: データの型がはっきりするため、予期しない解釈を防ぐ

Pythonでの実装例

sqlite3での実装

Python標準ライブラリのsqlite3では、プレースホルダーとして「?」を使用します。

import sqlite3

# データベース接続
conn = sqlite3.connect('example.db')
cursor = conn.cursor()

# 安全な実装例
username = request.form['username']
password = request.form['password']

# プレースホルダーを使用
query = "SELECT * FROM users WHERE username=? AND password=?"
cursor.execute(query, (username, password))

result = cursor.fetchone()

if result:
    print("ログイン成功")
else:
    print("ログイン失敗")

conn.close()

複数のレコードを挿入する場合は、executemany()を使用します。

# 複数レコードの挿入
users = [
    ('user1', 'pass1', 'user1@example.com'),
    ('user2', 'pass2', 'user2@example.com'),
    ('user3', 'pass3', 'user3@example.com'),
]

query = "INSERT INTO users (username, password, email) VALUES (?, ?, ?)"
cursor.executemany(query, users)
conn.commit()

MySQLでの実装(mysql-connector-python)

MySQLでは、プレースホルダーとして「%s」を使用します。

import mysql.connector

# データベース接続
conn = mysql.connector.connect(
    host='localhost',
    user='your_user',
    password='your_password',
    database='your_database'
)
cursor = conn.cursor()

# 安全な実装例
username = request.form['username']
email = request.form['email']

query = "SELECT * FROM users WHERE username=%s AND email=%s"
cursor.execute(query, (username, email))

result = cursor.fetchone()

# UPDATE文の例
new_email = 'new@example.com'
user_id = 123

update_query = "UPDATE users SET email=%s WHERE id=%s"
cursor.execute(update_query, (new_email, user_id))
conn.commit()

cursor.close()
conn.close()

注意点として、MySQLのプレースホルダーは「%s」ですが、これはPythonの文字列フォーマットの「%s」とは異なります。必ずexecute()の第二引数としてタプルで渡してください。

PostgreSQLでの実装(psycopg2)

PostgreSQLでも「%s」をプレースホルダーとして使用しますが、psycopg2では名前付きプレースホルダーも使えます。

import psycopg2

# データベース接続
conn = psycopg2.connect(
    host='localhost',
    database='your_database',
    user='your_user',
    password='your_password'
)
cursor = conn.cursor()

# 位置指定プレースホルダー
username = 'testuser'
query = "SELECT * FROM users WHERE username=%s"
cursor.execute(query, (username,))

# 名前付きプレースホルダー
search_params = {
    'username': 'testuser',
    'min_age': 20,
    'max_age': 30
}

query = """
    SELECT * FROM users 
    WHERE username=%(username)s 
    AND age BETWEEN %(min_age)s AND %(max_age)s
"""
cursor.execute(query, search_params)

results = cursor.fetchall()

cursor.close()
conn.close()

名前付きプレースホルダーを使うと、パラメータが多い場合でも可読性が向上します。

やってはいけない対策

文字列連結の危険性

f-stringや+演算子による文字列連結は絶対に避けてください。

# 絶対にやってはいけない例
username = request.form['username']

# f-stringによる連結(危険)
query = f"SELECT * FROM users WHERE username='{username}'"
cursor.execute(query)

# +演算子による連結(危険)
query = "SELECT * FROM users WHERE username='" + username + "'"
cursor.execute(query)

# %フォーマットによる連結(危険)
query = "SELECT * FROM users WHERE username='%s'" % username
cursor.execute(query)

これらはすべてSQLインジェクションの脆弱性を生み出します。

エスケープ処理だけでは不十分な理由

手動でエスケープ処理を行う方法も推奨されません。

# 不十分な対策例
def escape_string(s):
    return s.replace("'", "''")

username = escape_string(request.form['username'])
query = f"SELECT * FROM users WHERE username='{username}'"
cursor.execute(query)

この方法には以下の問題があります。

  • エスケープ処理の実装ミスのリスク
  • データベースごとに異なるエスケープルール
  • 数値型など文字列以外のデータへの対応が困難
  • 文字エンコーディングによる脆弱性

プレースホルダーを使えば、これらの問題をデータベースドライバが自動的に処理してくれます。

よくある間違い

プレースホルダーを使っているつもりでも、以下のような間違いがあります。

# 間違い1: テーブル名や列名をプレースホルダーにする
table_name = request.args.get('table')
query = "SELECT * FROM ?"
cursor.execute(query, (table_name,))  # エラーになる

# 間違い2: LIKE句での誤った使い方
search_term = request.form['search']
query = "SELECT * FROM products WHERE name LIKE '%?%'"
cursor.execute(query, (search_term,))  # プレースホルダーが機能しない

# 正しいLIKE句の使い方
search_term = f"%{request.form['search']}%"
query = "SELECT * FROM products WHERE name LIKE ?"
cursor.execute(query, (search_term,))

テーブル名や列名は動的に変更せざるを得ない場合、ホワイトリスト方式で検証してください。

# テーブル名の安全な扱い方
ALLOWED_TABLES = ['users', 'products', 'orders']

table_name = request.args.get('table')
if table_name not in ALLOWED_TABLES:
    raise ValueError("Invalid table name")

# 許可されたテーブル名のみ使用
query = f"SELECT * FROM {table_name} WHERE id=?"
cursor.execute(query, (record_id,))

その他の対策との組み合わせ

プレースホルダーは非常に有効ですが、多層防御の考え方で他の対策も併用しましょう。

入力値検証

データベースに渡す前に、入力値の妥当性を検証します。

import re

def validate_username(username):
    # 英数字とアンダースコアのみ許可
    if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
        raise ValueError("Invalid username format")
    return username

def validate_email(email):
    # 簡易的なメールアドレス検証
    if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
        raise ValueError("Invalid email format")
    return email

# 使用例
try:
    username = validate_username(request.form['username'])
    email = validate_email(request.form['email'])
    
    query = "INSERT INTO users (username, email) VALUES (?, ?)"
    cursor.execute(query, (username, email))
    conn.commit()
except ValueError as e:
    print(f"Validation error: {e}")

最小権限の原則

データベースユーザーには必要最小限の権限のみを付与します。

-- アプリケーション用のユーザーを作成
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'secure_password';

-- 必要なテーブルへの必要な権限のみ付与
GRANT SELECT, INSERT, UPDATE ON mydb.users TO 'app_user'@'localhost';
GRANT SELECT ON mydb.products TO 'app_user'@'localhost';

-- DROP, DELETE, ALTER などの危険な権限は付与しない

仮にSQLインジェクション攻撃を受けても、データベースユーザーの権限が限定されていれば被害を最小限に抑えられます。

WAFの活用

Web Application Firewall(WAF)を導入することで、SQLインジェクション攻撃のパターンを検知してブロックできます。

  • AWS WAF
  • Cloudflare WAF
  • ModSecurity

ただし、WAFは補助的な対策であり、プレースホルダーによる根本的な対策の代わりにはなりません。

まとめ

SQLインジェクション対策の要点をまとめます。

  • SQLインジェクションは深刻な脆弱性であり、情報漏洩やデータ破壊につながる
  • プレースホルダーを使用することで、SQL文とデータを完全に分離できる
  • 文字列連結や手動エスケープは避け、必ずプレースホルダーを使用する
  • Pythonでは各データベースライブラリが提供するプレースホルダー機能を活用する
  • 入力検証、最小権限、WAFなど多層防御を心がける

プレースホルダーの使用は、SQLインジェクション対策として最も確実で簡単な方法です。すべてのSQL文でプレースホルダーを使用する習慣をつけましょう。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?