【Python】保存先が指定フォルダーの外に出ないようにする:パスの検証

PythonのTopに戻る

文字列の前方一致で保存先を判定しない

利用者から受け取った相対名を保存フォルダーへ足すとき、../で外へ出たり、絶対パスによって基準が無視されたりすることがある。保存先が基準内かを調べるには、パスとして解釈してresolve()で整理し、is_relative_to()で包含関係を確認する。文字列のstartswith()では、dataとdata_backupを混同するので境界検査に使わない。

以下は信頼できる作業ディレクトリ内での入力ミス防止を目的にした検証関数である。相対名だけを認め、..を含む表記、絶対パス、Windowsのドライブ付き表記、バックスラッシュを拒否する。最後に既存のシンボリックリンクも解決した結果が基準内か確認する。実際にファイルを書き込む機能は含めない。

基準と候補を解決してから比較する

example.py

from pathlib import Path, PureWindowsPath
from tempfile import TemporaryDirectory


def output_path(base, name):
    base = Path(base).resolve(strict=True)
    if not base.is_dir():
        raise ValueError("base must be a directory")
    if not isinstance(name, str) or not name or "\\" in name:
        raise ValueError("use a nonempty relative path with forward slashes")
    relative = Path(name)
    if relative.is_absolute() or PureWindowsPath(name).drive or ".." in relative.parts:
        raise ValueError("absolute paths and parent traversal are not allowed")
    candidate = (base / relative).resolve(strict=False)
    if candidate == base or not candidate.is_relative_to(base):
        raise ValueError("destination is outside the allowed directory")
    return candidate


with TemporaryDirectory() as temporary:
    root = Path(temporary)
    base, outside = root / "data", root / "data_backup"
    base.mkdir()
    outside.mkdir()
    (base / "linked").symlink_to(outside, target_is_directory=True)
    valid = output_path(base, "reports/summary.csv")
    assert valid == base / "reports" / "summary.csv"
    print(valid.relative_to(base).as_posix())
    for name in ("../escape.txt", "linked/escape.txt", ".", "C:/temp/out.txt", "a\\b.txt", str(outside / "absolute.txt")):
        try:
            output_path(base, name)
        except ValueError:
            pass
        else:
            raise AssertionError("unsafe path was accepted")
    print("rejected cases:", 6)
    assert not valid.exists()

実行結果

reports/summary.csv
rejected cases: 6

字面の検査と、解決後の検査の役割

最初の検査は、保存名として認めない形式を分かりやすく拒否するためのものだ。..を解決すれば結果が基準内になる場合もあるが、この例では紛らわしい表記自体を許さない。WindowsのC:/…はLinuxのPathでは同じ意味の絶対パスにならないため、PureWindowsPathでドライブ部分も調べている。

resolve(strict=False)は、これから作る保存先がまだ存在しない場合も扱える。既存部分のシンボリックリンクを解決するため、基準内にあるlinkedが外のdata_backupを指していれば、その下の候補も外側として判定できる。一方で、基準フォルダーはstrict=Trueで存在を確認し、誤った基準のまま検証を続けないようにした。

is_relative_to()はパスの構成要素として基準の下にあるかを調べる。単に文字列の先頭が同じかを比べるより、フォルダーの区切りを正しく扱える。ただしこのメソッドだけでリンクや..を自動的に解決するわけではないため、解決する工程を先に置く必要がある。

これは敵対的な操作を防ぐ完全なサンドボックスではない

検証してからopen()するまでの間に、別のプロセスがフォルダーをシンボリックリンクへ差し替える可能性がある。resolve()したパスを返すだけでは、この時間差の攻撃を防げない。共有された書き込み可能領域など、他者が構造を変更できる場所への安全な保存を、この関数だけで保証しない。

強い境界が必要な場合は、書き込み権限を分離した保存領域、ディレクトリ記述子を基準にしたOS固有の操作、シンボリックリンクをたどらないオープンなどを含めて設計する。利用できる機能や制約はOSごとに異なるため、Linuxで動いた文字列検証をそのままWindowsのセキュリティ保証へ広げない。

検証関数はファイルの作成可否までは保証しない。親ディレクトリが存在しない、権限がない、予約名である、容量が足りない、といった理由で保存は失敗しうる。また、既存ファイルを上書きしてよいかも別の判断である。新規保存ならxモードなどで上書きを避け、更新なら保存手順を別に設計する。

入力仕様を広げすぎない

この例は/で区切った相対名だけを受け付ける。URLエンコードされた入力を受け取るシステムでは、どの段階でデコードされるかも重要で、検証後に別の処理で意味が変わる構成は避ける。単一のファイル名だけが必要なら、サブフォルダーを許さず候補名をアプリケーション側で発行する方が単純になる。

テストでは通常の未作成パスを受け入れ、基準外への移動、リンク経由の移動、基準そのもの、異なるOSの形式を拒否した。戻り値を得ても実ファイルが作成されていないことも確認している。検証の単体テストと実際の保存処理のテストを分けると、どちらの前提が崩れたかを追いやすい。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:実行する場所でファイルが見つからない:相対パスの基準をそろえる / 保存途中で元ファイルを壊さない:一時ファイルから置き換える

PythonのTopに戻る