デコードしてから正規化する
URLパスなどの外部入力を扱うとき、パーセントデコードとパス正規化(./.. セグメントの除去)をどちらの順で行うかで安全性が変わる、という原則。デコードを先、正規化を後にする。
順序を逆にすると何が起きるか
正規化を先に行うと、区切り文字がエンコードされている場合にセグメント分割が期待通りにならない。
例として ..%2f..%2fbuzz を考える。%2f はこの時点ではただの3文字なので、/ で分割するとセグメントは1個になる。
"..%2f..%2fbuzz"
→ '/' で分割 → ["..%2f..%2fbuzz"] 1セグメント
→ ".." の除去 → ["..%2f..%2fbuzz"] 名前が ".." ではないので何も起きない
→ パーセントデコード → "../../buzz" ここで初めて "../" が現れる
正規化フェーズを通過した後で ../ が出現するので、除去処理が意味を成さない。順序を入れ替えると成立する。
"..%2f..%2fbuzz"
→ パーセントデコード → "../../buzz"
→ '/' で分割 → ["..", "..", "buzz"]
→ ".." の除去 → ["buzz"]
同じ構図は、パスに限らず「エンコードされた入力に対して意味的なチェックを先にかけてしまう」場面全般で起きる。→ パストラバーサル
デコードはちょうど1回
デコードを先にするとしても、変化がなくなるまで繰り返してはいけない。繰り返すと二重エンコードされた入力が区切り文字に化ける。
"..%252f..%252fbuzz"
→ 1回デコード → "..%2f..%2fbuzz" ← ここで止める。"%2f" を含む1個のファイル名
→ 2回デコード → "../../buzz" ← 区切り文字が生まれてしまう
RFC 3986 でもパーセントエンコードされたオクテットは1回だけデコードされるものとして定義されており、「ちょうど1回」が正しい。cohttpの修正では、この不変条件をテストで固定している。
(* docroot = "/foo/bar/baz" *)
("..%2f..%2fbuzz", "/foo/bar/baz/buzz"); (* 1回デコードされ、除去される *)
("..%252f..%252fbuzz", "/foo/bar/baz/..%2f..%2fbuzz"); (* デコードは1回で止まる *)
正規化はルートで打ち止めにする
.. を除去する際、スタックが空のときの .. は「捨てる」実装にすると、結果は決してルートより上に上がらない相対パスになる。RFC 3986 の remove_dot_segments も同じ挙動。
let remove_dot_segments path =
let rec go acc = function
| [] -> List.rev acc
| ("" | ".") :: rest -> go acc rest
| ".." :: rest -> (
match acc with [] -> go [] rest | _ :: tl -> go tl rest)
| seg :: rest -> go (seg :: acc) rest
in
path |> String.split_on_char '/' |> go [] |> String.concat "/"
これにより /../../etc/passwd は etc/passwd になる。docrootと連結しても外に出られない。
区切り文字として何を認めるか
分割に使う文字を増やすかどうかは移植性とのトレードオフになる。POSIXでは \ はパス区切りではなく通常の文字なので、..%5c..%5cwindows を ..\..\windows という1個のファイル名として扱う実装もある(cohttpはこちら)。Windows上で同じコードを動かす場合は別の考慮が要る。