第2部 セキュリティ編
第10章 セキュアコーディングの基本
脆弱性の多くは「外部からの入力を安全でない方法で使う」ことから生まれます。代表的な脆弱性を、脆弱なコードと安全なコードの対比で学びます。
🎯 この章で学ぶこと
- 入力検証(バリデーション)の考え方
- SQLインジェクションと対策
- コマンドインジェクションと対策
- パストラバーサルと対策
- 機密情報をコードに書かない
以下に登場する「脆弱なコード」は、やってはいけない例として示すものです。自分の学習環境で挙動を確認するのは構いませんが、実在するシステムに対して試してはいけません。
10.1 すべての基本 — 入力を信用しない
第2章で触れた大原則を、もう一度強調します。外部から来るデータ(ユーザー入力、ファイル、通信、環境変数)はすべて「汚染されている」とみなす。これがセキュアコーディングの出発点です。
入力検証には2つのアプローチがあります。
- 許可リスト方式(ホワイトリスト) — 「これだけOK」を定義し、それ以外を拒否する。原則こちらを使う
- 拒否リスト方式(ブラックリスト) — 「これはNG」を列挙する。抜け漏れが必ず出るため補助的な位置づけ
# 許可リスト方式:操作名を「あらかじめ決めた集合」だけに限定する
ALLOWED_ACTIONS = {"read", "list", "status"}
def run_action(action):
if action not in ALLOWED_ACTIONS:
raise ValueError(f"許可されていない操作です: {action}")
print(f"{action} を実行します")
run_action("read") # OK
run_action("delete") # ValueError — 想定外は最初から弾く
10.2 SQLインジェクション
Webアプリで最も有名かつ危険な脆弱性です。ユーザー入力を文字列連結でSQL文に埋め込むと発生します。
# ❌ 脆弱なコード(絶対に真似しない)
def find_user_bad(cursor, username):
query = "SELECT * FROM users WHERE name = '" + username + "'"
cursor.execute(query)
return cursor.fetchall()
ここで username に ' OR '1'='1 を入れられると、SQL文はこうなります。
SELECT * FROM users WHERE name = '' OR '1'='1'
'1'='1' は常に真なので、全ユーザーの情報が返ってしまいます。'; DROP TABLE users; -- ならテーブル削除すら狙われます。原因は「データであるべき入力が、SQLの命令として解釈された」ことです。
対策 — プレースホルダ(パラメータ化クエリ)
# ✅ 安全なコード:値は必ずプレースホルダで渡す
def find_user_good(cursor, username):
query = "SELECT * FROM users WHERE name = ?"
cursor.execute(query, (username,)) # 値は第2引数で別に渡す
return cursor.fetchall()
プレースホルダ(? や %s)を使うと、入力は常にただのデータとして扱われ、SQLの命令として解釈されません。何が入力されても安全です。SQLiteでの完全な例を示します。
import sqlite3
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE users (id INTEGER, name TEXT)")
cur.execute("INSERT INTO users VALUES (1, 'tanaka')")
cur.execute("INSERT INTO users VALUES (2, 'suzuki')")
conn.commit()
# 悪意ある入力を渡しても安全
evil = "' OR '1'='1"
cur.execute("SELECT * FROM users WHERE name = ?", (evil,))
print(cur.fetchall()) # [] — 何も返らない(そんな名前は存在しない)
ユーザー入力を + や f文字列でSQLに混ぜないこと。値はすべてプレースホルダ経由で渡す。これを守るだけでSQLインジェクションのほぼすべてを防げます。ORM(SQLAlchemyなど)を使う場合も、生SQLに入力を埋め込まないという原則は同じです。
10.3 コマンドインジェクション
Pythonから外部コマンド(pingやnslookupなど)を実行する処理でよく起きます。IT部門の運用スクリプトは特に要注意です。
import os
# ❌ 脆弱なコード:入力をそのままシェルに渡している
def ping_bad(host):
os.system("ping -c 1 " + host)
host に 8.8.8.8; rm -rf ~ を入れられると、pingの後に任意のコマンドが実行されてしまいます。;、|、&&、バッククォートといったシェルの特殊文字が悪用されます。
対策 — subprocessで引数をリスト渡し、shell=Falseにする
import subprocess
import ipaddress
# ✅ 安全なコード
def ping_good(host):
# 1) まず入力を検証(IPアドレスとして妥当か)
ipaddress.ip_address(host) # 不正ならここでValueError
# 2) 引数をリストで渡し、シェルを経由しない(shell=False が既定)
result = subprocess.run(
["ping", "-c", "1", host],
capture_output=True, text=True, timeout=5,
)
return result.returncode == 0
print(ping_good("8.8.8.8")) # True/False
# ping_good("8.8.8.8; rm -rf ~") # ValueError で安全に停止
ポイントは2つです。①引数をリストで渡す(["ping", "-c", "1", host])ことで、host 全体が1つの引数として扱われ、特殊文字が解釈されません。②shell=True を使わない。どうしても必要な場合を除き、shell=True は避けます。
コードレビューで os.system(...) や subprocess.run(..., shell=True) に外部入力が流れ込んでいたら、コマンドインジェクションを疑ってください。第13章で作るツールでも、外部コマンド呼び出しは徹底してリスト渡しで書きます。
10.4 パストラバーサル(ディレクトリトラバーサル)
ファイル名をユーザーに指定させる処理で、../ を悪用して想定外のファイルにアクセスされる脆弱性です。
import os
BASE_DIR = "/var/app/uploads"
# ❌ 脆弱なコード
def read_file_bad(filename):
path = os.path.join(BASE_DIR, filename)
with open(path, encoding="utf-8") as f:
return f.read()
# filename に "../../etc/passwd" を渡すと BASE_DIR の外に出てしまう
対策 — 正規化して、基準ディレクトリの中にあるか確認する
import os
BASE_DIR = os.path.realpath("/var/app/uploads")
def read_file_good(filename):
# 実際の絶対パスに正規化(../ を解決する)
path = os.path.realpath(os.path.join(BASE_DIR, filename))
# 正規化後のパスが BASE_DIR の内側にあることを必ず確認
if not path.startswith(BASE_DIR + os.sep):
raise ValueError("不正なパスです")
with open(path, encoding="utf-8") as f:
return f.read()
pathlib を使うとより明快に書けます。
from pathlib import Path
BASE_DIR = Path("/var/app/uploads").resolve()
def read_file_pathlib(filename):
path = (BASE_DIR / filename).resolve()
if BASE_DIR not in path.parents and path != BASE_DIR:
raise ValueError("不正なパスです")
return path.read_text(encoding="utf-8")
10.5 機密情報をコードに書かない(ハードコーディング禁止)
パスワード、APIキー、トークンをソースコードに直接書くのは重大な事故のもとです。GitHubなどに push した瞬間、世界中から検索される可能性があります。
# ❌ 絶対にやってはいけない
API_KEY = "sk_live_1a2b3c4d5e6f7g8h"
DB_PASSWORD = "P@ssw0rd123"
# ✅ 環境変数から読み込む
import os
API_KEY = os.environ["API_KEY"] # 未設定なら例外で気づける
DB_PASSWORD = os.environ.get("DB_PASSWORD", "") # 既定値つき
- 機密情報は環境変数や、
.envファイル(.gitignoreに必ず追加)から読み込む - 本番環境では、クラウドのシークレットマネージャ(AWS Secrets Manager等)や社内の資格情報管理を使う
- 誤ってコミットしてしまったら、その鍵は漏えいしたものとして即座に無効化・再発行する(履歴から消すだけでは不十分)
- コミット前に
git diffで機密情報が含まれていないか確認する習慣を
10.6 セキュアコーディング チェックリスト
コードを書き終えたら、この観点で自己レビューしましょう。
| 観点 | 確認内容 |
|---|---|
| 入力検証 | 外部入力を、許可リストで検証してから使っているか |
| SQL | すべてプレースホルダを使い、文字列連結していないか |
| コマンド実行 | 引数をリスト渡しし、shell=True を避けているか |
| ファイルパス | 基準ディレクトリ内に収まるか検証しているか |
| シークレット | 鍵・パスワードをコードに直書きしていないか |
| エラー処理 | 例外を握りつぶさず、詳細を利用者に見せていないか(第6章) |
| 最小権限 | 必要以上の権限で動いていないか(第9章) |
まとめ
- すべての外部入力を疑い、許可リストで検証する
- SQLはプレースホルダ、コマンドはリスト渡し+
shell=False - ファイルパスは正規化し、基準ディレクトリ内かを検証する
- 機密情報はコードに書かず環境変数などから読み込む。漏れたら即無効化
- 脆弱性の多くは「入力を安全でない方法で使う」ことに集約される
練習問題
次のコードにはSQLインジェクションの脆弱性があります。安全な形に書き直してください。
def delete_user(cursor, user_id):
cursor.execute(f"DELETE FROM users WHERE id = {user_id}")
解答を見る
def delete_user(cursor, user_id):
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
f文字列での埋め込みをやめ、値をプレースホルダで渡します。さらに堅牢にするなら、呼び出し前に user_id が整数であることを検証(int(user_id))しておくとよいでしょう。
ユーザーにホスト名を入力させ、nslookup を実行するツールを、コマンドインジェクションを防いだ形で書いてください(ホスト名は英数字・ドット・ハイフンのみ許可)。
解答を見る
import subprocess
import re
def safe_nslookup(host):
# 許可リスト方式:英数字・ドット・ハイフンのみ
if not re.fullmatch(r"[A-Za-z0-9.\-]+", host):
raise ValueError("不正なホスト名です")
result = subprocess.run(
["nslookup", host],
capture_output=True, text=True, timeout=5,
)
return result.stdout
print(safe_nslookup("example.com"))
# safe_nslookup("example.com; rm -rf ~") → ValueError
「入力検証(許可リスト)」と「リスト渡し」の二重の防御になっています。re.fullmatch は文字列全体がパターンに一致するかを見ます(正規表現は第13章で詳説)。
あるスクリプトに SLACK_TOKEN = "xoxb-1234..." と直接書かれているのを見つけました。あなたはIT部門として、どう対応すべきですか。優先順位をつけて述べてください。
解答を見る
- そのトークンを即座に無効化・再発行する(すでに漏えいしたものとして扱う)。コードから消すだけでは、過去のコミット履歴やバックアップに残るため不十分。
- 新しいトークンは環境変数やシークレットマネージャから読み込むようコードを修正する。
- リポジトリの履歴に残っていないか確認し、必要なら履歴の書き換え(BFG等)を検討する。
- 再発防止として、コミット前に機密情報を検出する仕組み(
gitleaks等のスキャナやpre-commitフック)を導入する。