ファイルパスの検証とTOCTOU
正規化時の安全性だけでなく、openする時点の対象とリンク追跡の条件を説明します。
説明
ファイル名を保存ディレクトリへ連結しても、相対パスやシンボリックリンクによって外側のファイルを指す場合があります。文字列の見た目と、OSが実際に開く対象を対応付けて調べます。
一時ディレクトリに公開用と外側の二つの試料を作り、通常名、親ディレクトリ、リンクを順に試します。最後に検証後の差替えを再現し、単一ファイル名とディレクトリFDを使うopenへ変更します。
正規化時の安全性だけでなく、openする時点の対象とリンク追跡の条件を説明します。
1公開領域と外側の試料を配置する
files/report.txtが公開用、private.txtは一つ外側です。aliasは外側へのリンクです。
from sd08 import *
lab = Paths()固定ファイルとリンクを作る
print(sorted(p.name for p in lab.root.iterdir()))
print(os.readlinkリンクに記録された参照先を読む(lab.root/"alias"))files内にはreport.txtとaliasがあり、aliasの参照先は../private.txtです。すべて今回の一時資料内に置いた固定データです。
2通常名で解決と読取りを試す
公開用の通常名を同じ基点から参照します。
print(lab.resolve正規化後の場所と包含を確認する("report.txt"))
print(lab.unsafe通常のファイルopenで読む("report.txt"))解決先はfiles/report.txt、領域内はTrueでpublic reportを読みます。これは通常ケースです。
3親ディレクトリを含む入力を試す
../を含む名前は、連結した基点の外へ移動します。
print(lab.resolve("../private.txt"親へ移動する相対要素を渡す))
print(lab.unsafe("../private.txt"))包含判定はFalseですが、単なる連結とopenはoutside dataを返します。ディレクトリ文字列を前に付けるだけでは制限になりません。
4リンクによる領域外参照を確かめる
入力に../がなくてもaliasの解決先は外側です。
print(lab.resolve("alias")リンクを解決した先を調べる)
print(lab.unsafe("alias"))包含はFalse、通常openはoutside dataです。入力文字列の../検出だけではリンクの参照先を捕捉できません。
5検証とopenの間で対象を差し替える
report.txtの包含を確認した後、その試料ファイルをリンクへ置き換えます。
print(lab.resolve("report.txt")[1])
(lab.root/"report.txt").unlink()検証した試料を差し替える
(lab.root/"report.txt").symlink_to同名で外側を指すリンクを作る("../private.txt")
print(lab.unsafe("report.txt"))検証時はTrueだったのにoutside dataを読みます。これは時点の異なる検証と利用のずれです。共有ディレクトリの変更権限も防御の一部になります。
6ディレクトリFDとリンク追跡禁止で開く
singleは保存済みのディレクトリFDを基点とし、許可された単一名だけをO_NOFOLLOWで開きます。
print(lab.single単一名・dir_fd・O_NOFOLLOWで開く("../private.txt"))
print(lab.single("alias"))
print(lab.single("report.txt"))親要素はname_rejected、二つのリンクはopen_rejectedです。O_NOFOLLOWは末尾のリンクに作用するため、ここでは中間ディレクトリを入力させない設計と組み合わせています。
7通常ファイルへ戻して成功条件を確認する
同じ公開試料を通常ファイルとして作り直します。
(lab.root/"report.txt").unlink()
(lab.root/"report.txt").write_text("public report")通常ファイルを戻す
print(lab.single同じ制限の下で再度開く("report.txt"))
print((lab.base/"private.txt").read_text())公開ファイルは読め、外側の試料はoutside dataのままです。通常利用を保ちつつ、リンクで対象を変える経路を拒否しました。
まとめ
パスの文字列だけでなく解決先を確認します。
検証後の差替えを考え、open時点の条件を設けます。
単一名の例を任意の多段パスへそのまま拡張しません。
操作履歴と解説を振り返れます。
Python 3.15 / 固定の公開試料
from sd08 import *
lab = Paths()
print(sorted(p.name for p in lab.root.iterdir()))
print(os.readlink(lab.root/"alias"))['alias', 'report.txt'] ../private.txt
print(lab.resolve("report.txt"))
print(lab.unsafe("report.txt"))('files/report.txt', True)
public reportprint(lab.resolve("../private.txt"))
print(lab.unsafe("../private.txt"))('private.txt', False)
outside dataprint(lab.resolve("alias"))
print(lab.unsafe("alias"))('private.txt', False)
outside dataprint(lab.resolve("report.txt")[1])
(lab.root/"report.txt").unlink()
(lab.root/"report.txt").symlink_to("../private.txt")
print(lab.unsafe("report.txt"))True outside data
print(lab.single("../private.txt"))
print(lab.single("alias"))
print(lab.single("report.txt"))name_rejected open_rejected open_rejected
(lab.root/"report.txt").unlink()
(lab.root/"report.txt").write_text("public report")
print(lab.single("report.txt"))
print((lab.base/"private.txt").read_text())public report outside data
演習完了