🎯 この章で学ぶこと

  • 入力検証(バリデーション)の考え方
  • SQLインジェクションと対策
  • コマンドインジェクションと対策
  • パストラバーサルと対策
  • 機密情報をコードに書かない
⚠️ この章の脆弱なコードについて

以下に登場する「脆弱なコード」は、やってはいけない例として示すものです。自分の学習環境で挙動を確認するのは構いませんが、実在するシステムに対して試してはいけません。

10.1 すべての基本 — 入力を信用しない

第2章で触れた大原則を、もう一度強調します。外部から来るデータ(ユーザー入力、ファイル、通信、環境変数)はすべて「汚染されている」とみなす。これがセキュアコーディングの出発点です。

入力検証には2つのアプローチがあります。

# 許可リスト方式:操作名を「あらかじめ決めた集合」だけに限定する
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())   # [] — 何も返らない(そんな名前は存在しない)
✅ 鉄則:SQLを文字列連結で組み立てない

ユーザー入力を + や f文字列でSQLに混ぜないこと。値はすべてプレースホルダ経由で渡す。これを守るだけでSQLインジェクションのほぼすべてを防げます。ORM(SQLAlchemyなど)を使う場合も、生SQLに入力を埋め込まないという原則は同じです。

10.3 コマンドインジェクション

Pythonから外部コマンド(pingやnslookupなど)を実行する処理でよく起きます。IT部門の運用スクリプトは特に要注意です。

import os

# ❌ 脆弱なコード:入力をそのままシェルに渡している
def ping_bad(host):
    os.system("ping -c 1 " + host)

host8.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 と 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章)

まとめ

練習問題

問題 10-1

次のコードには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))しておくとよいでしょう。

問題 10-2

ユーザーにホスト名を入力させ、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章で詳説)。

問題 10-3

あるスクリプトに SLACK_TOKEN = "xoxb-1234..." と直接書かれているのを見つけました。あなたはIT部門として、どう対応すべきですか。優先順位をつけて述べてください。

解答を見る
  1. そのトークンを即座に無効化・再発行する(すでに漏えいしたものとして扱う)。コードから消すだけでは、過去のコミット履歴やバックアップに残るため不十分。
  2. 新しいトークンは環境変数やシークレットマネージャから読み込むようコードを修正する。
  3. リポジトリの履歴に残っていないか確認し、必要なら履歴の書き換え(BFG等)を検討する。
  4. 再発防止として、コミット前に機密情報を検出する仕組み(gitleaks 等のスキャナやpre-commitフック)を導入する。