デコードしてから正規化する

URLパスなどの外部入力を扱うとき、パーセントデコードとパス正規化(./.. セグメントの除去)をどちらの順で行うかで安全性が変わる、という原則。デコードを先、正規化を後にする。

#security

順序を逆にすると何が起きるか

正規化を先に行うと、区切り文字がエンコードされている場合にセグメント分割が期待通りにならない。

例として ..%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/passwdetc/passwd になる。docrootと連結しても外に出られない。

区切り文字として何を認めるか

分割に使う文字を増やすかどうかは移植性とのトレードオフになる。POSIXでは \ はパス区切りではなく通常の文字なので、..%5c..%5cwindows..\..\windows という1個のファイル名として扱う実装もある(cohttpはこちら)。Windows上で同じコードを動かす場合は別の考慮が要る。

出典

作成日時: 2026-08-30 20:07 / 更新日時: 2026-08-30 20:07