Path.resolve() で .. とシンボリックリンクを解決した後、許可ルート配下であることを relative_to() で確認します。ディレクトリトラバーサルとは
たとえばダウンロードAPIが、次のように入力値を公開ディレクトリへ連結しているとします。
from pathlib import Path
DOWNLOAD_ROOT = Path("/srv/app/downloads")
def unsafe_download_path(filename: str) -> Path:
return DOWNLOAD_ROOT / filename
通常の report.pdf なら問題なさそうに見えます。しかし ../../config/settings.env のような入力では、OSが .. を親ディレクトリとして解釈し、許可範囲の外へ移動します。URLエンコードされた入力はWebフレームワーク側でデコードされ、同じパスになる場合があります。
「../を削除する」では不十分
filename.replace("../", "") のような処理は、サニタイズとして十分ではありません。区切り文字はOSで異なり、エンコード、絶対パス、重ねたドット、シンボリックリンクなど複数の表現があります。変換順序が変わると、検査後に危険な文字列が現れることもあります。
| 危険な対策 | 問題点 |
|---|---|
../ の文字列置換 | 別の区切り、エンコード、変換順序、シンボリックリンクを扱えない |
| 末尾の拡張子だけ確認 | パスの位置は制限できず、ファイル形式の保証にもならない |
startswith() で比較 | /safe-files-evil も /safe-files から始まる文字列として一致する |
Path.absolute() だけ使う | 絶対化するだけで、シンボリックリンクや .. を解決しない |
既存ファイルを安全に解決する関数
次の関数は、ダウンロードなど「既に存在する通常ファイルを読む」用途を対象にしています。
from pathlib import Path
class UnsafePathError(ValueError):
"""許可ルート外または不正なパスが指定された。"""
def resolve_existing_file(root: Path, user_path: str) -> Path:
"""user_pathをroot配下の既存ファイルとして安全に解決する。"""
if not isinstance(user_path, str) or not user_path:
raise UnsafePathError("ファイル名が空です")
if "\x00" in user_path:
raise UnsafePathError("NUL文字は使用できません")
try:
safe_root = root.resolve(strict=True)
except OSError as exc:
raise RuntimeError("許可ルートを解決できません") from exc
supplied = Path(user_path)
if supplied.is_absolute():
raise UnsafePathError("絶対パスは使用できません")
try:
candidate = (safe_root / supplied).resolve(strict=True)
except (OSError, ValueError) as exc:
raise UnsafePathError("ファイルを解決できません") from exc
try:
candidate.relative_to(safe_root)
except ValueError as exc:
raise UnsafePathError("許可ルート外のパスです") from exc
if not candidate.is_file():
raise UnsafePathError("通常ファイルではありません")
return candidate
関数の検証順序
- 空文字やNUL文字など、パスとして扱えない入力を拒否する
- 許可ルートを
resolve(strict=True)で実在する絶対パスへ変換する - 入力が絶対パスなら拒否する
- ルートと入力を結合し、
..とシンボリックリンクを解決する relative_to(safe_root)が成功することを確認する- 最後に通常ファイルであることを確認する
resolve(strict=True) は存在しないパスや解決できないシンボリックリンクで例外を出します。許可ルート内のリンクが外部ファイルを指す場合も、解決後のパスがルート外になるため拒否できます。
期待する動作をテストする
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as work:
base = Path(work)
public = base / "public"
public.mkdir()
(public / "docs").mkdir()
(public / "report.txt").write_text("public", encoding="utf-8")
(base / "secret.txt").write_text("secret", encoding="utf-8")
assert resolve_existing_file(public, "report.txt").name == "report.txt"
assert resolve_existing_file(public, "docs/../report.txt").name == "report.txt"
rejected = ["../secret.txt", str(base / "secret.txt"), ""]
for value in rejected:
try:
resolve_existing_file(public, value)
except UnsafePathError:
pass
else:
raise AssertionError(f"拒否されませんでした: {value!r}")
print("all tests passed")
URLデコードはどこで行うか
多くのWebフレームワークは、ルートパラメーターやクエリ文字列を一度デコードしてアプリケーションへ渡します。検証関数には、そのフレームワークが確定した文字列を渡します。アプリ側で何度も unquote() を繰り返すと、検査後に別の表現へ変わる二重デコード問題を生むため避けます。
アップロードは元のファイル名で保存しない
新規ファイルはまだ存在しないため、先ほどの strict=True 関数とは用途が違います。アップロードでは、ユーザーのファイル名を保存先に使わず、サーバー側で予測困難な名前を生成する方が安全です。
import secrets
from pathlib import Path
ALLOWED_SUFFIXES = {".png", ".jpg", ".pdf"}
def new_upload_name(original_name: str) -> str:
suffix = Path(original_name).suffix.lower()
if suffix not in ALLOWED_SUFFIXES:
raise ValueError("許可されていない拡張子です")
return f"{secrets.token_urlsafe(24)}{suffix}"
storage_name = new_upload_name("profile.png")
保存先ディレクトリはアプリケーションだけが書き込める場所に固定し、ファイルは排他的作成モードで開きます。拡張子だけで内容を信頼せず、ファイルサイズ、MIMEタイプ、実データも用途に応じて検証します。
それでも残る競合状態
パスを検証してから開くまでの間に、別プロセスがファイルやシンボリックリンクを差し替えると、検証時と利用時の対象が変わるTOCTOU競合が起こり得ます。この関数を使うディレクトリは、信頼できないユーザーに書き込み権限を与えないことが前提です。
攻撃者が同じディレクトリを書き換えられる高リスク環境では、文字列や Path だけの検証で完結させず、ディレクトリファイルディスクリプタを基準にしたOS固有API、シンボリックリンクを追跡しないオプション、サンドボックスやコンテナの権限制限を組み合わせます。
まとめ
ディレクトリトラバーサル対策は、危険な文字を消す「サニタイズ」ではなく、許可する対象を確定するパス検証として実装します。可能ならユーザーにはファイルIDだけを渡し、サーバー側のallowlistでパスへ変換します。パス入力が必要な場合は、実パスを解決してから許可ルートとの包含関係を確認し、OS権限でも被害範囲を制限します。
詳細は Python pathlib公式ドキュメント、CWE-22、OWASP Path Traversal を参照してください。