|
| 1 | +/- SPDX-License-Identifier: MPL-2.0 |
| 2 | + Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +
|
| 4 | + Valence Shell — Path-traversal containment proof (frontier item A-12) |
| 5 | +
|
| 6 | + Formalises the path-normalisation discipline implemented by |
| 7 | + `vsh::state::ShellState::resolve_path` in `impl/rust-cli/src/state.rs` |
| 8 | + and proves that the result is always within the sandbox root, |
| 9 | + regardless of `..` components in the input. |
| 10 | +
|
| 11 | + The 2026-02-12 audit caught a real CVE-class bug here |
| 12 | + (`resolve_path("../../etc/passwd")` escaped the sandbox before the |
| 13 | + `normalized.starts_with(root)` check + `..`-pop guard landed). This |
| 14 | + module is the regression-guard. |
| 15 | +
|
| 16 | + Companion property test: |
| 17 | + `impl/rust-cli/tests/security_tests.rs::property_resolve_path_stays_within_sandbox` |
| 18 | +-/ |
| 19 | + |
| 20 | +namespace PathTraversal |
| 21 | + |
| 22 | +-- Path representation matches `FilesystemModel.Path` (List String of |
| 23 | +-- components). Kept local to avoid coupling: this module is about |
| 24 | +-- containment of a path-string normalisation, not about the |
| 25 | +-- filesystem semantics. |
| 26 | +abbrev Path := List String |
| 27 | + |
| 28 | +-- A raw path component as parsed from a user-supplied string. |
| 29 | +-- Mirrors `std::path::Component` in Rust (the three variants |
| 30 | +-- `resolve_path` switches on; we elide RootDir/Prefix since the |
| 31 | +-- Rust code strips them before normalisation). |
| 32 | +inductive Component where |
| 33 | + | parentDir : Component |
| 34 | + | curDir : Component |
| 35 | + | normal : String → Component |
| 36 | + deriving DecidableEq, Repr |
| 37 | + |
| 38 | +-- Step the normaliser by one component. Matches the Rust loop body: |
| 39 | +-- `..` → pop if normalized strictly extends root (still within root |
| 40 | +-- AND not at root); silently clamp at root otherwise |
| 41 | +-- `.` → skip |
| 42 | +-- name → append |
| 43 | +def applyComponent (root normalized : Path) (c : Component) : Path := |
| 44 | + match c with |
| 45 | + | Component.parentDir => |
| 46 | + if root <+: normalized ∧ normalized ≠ root then |
| 47 | + normalized.dropLast |
| 48 | + else |
| 49 | + normalized |
| 50 | + | Component.curDir => normalized |
| 51 | + | Component.normal s => normalized ++ [s] |
| 52 | + |
| 53 | +-- Drive the normaliser over a list of components, starting from |
| 54 | +-- `root` as the initial accumulator (matches the Rust implementation, |
| 55 | +-- which starts `normalized` empty and pre-joins with root). |
| 56 | +def normalizeRaw (root : Path) (raw : List Component) : Path := |
| 57 | + raw.foldl (applyComponent root) root |
| 58 | + |
| 59 | +-- The final safety check (Rust line 589-593 in `state.rs`): if the |
| 60 | +-- accumulated result somehow does not start with root, clamp to root. |
| 61 | +-- This is defensive; the foldl invariant we prove below means the |
| 62 | +-- check never fires, but the clamp is what makes the theorem closed- |
| 63 | +-- form regardless of `applyComponent` correctness. |
| 64 | +def normalizePath (root : Path) (raw : List Component) : Path := |
| 65 | + let n := normalizeRaw root raw |
| 66 | + if root <+: n then n else root |
| 67 | + |
| 68 | +-- --------------------------------------------------------------------- |
| 69 | +-- Helper lemmas |
| 70 | +-- --------------------------------------------------------------------- |
| 71 | + |
| 72 | +-- Appending on the right preserves prefix (`xs <+: ys → xs <+: ys ++ zs`). |
| 73 | +-- This is the key compositional step used by the `Component.normal` |
| 74 | +-- case. Uses `List.prefix_append` (every list is a prefix of itself |
| 75 | +-- appended) plus transitivity of `<+:`. |
| 76 | +theorem isPrefix_append_right |
| 77 | + (xs ys zs : Path) (h : xs <+: ys) : |
| 78 | + xs <+: ys ++ zs := |
| 79 | + h.trans (List.prefix_append ys zs) |
| 80 | + |
| 81 | +-- Dropping the last element preserves a prefix, provided the prefix |
| 82 | +-- itself wasn't the whole list (otherwise dropping erases an element |
| 83 | +-- of the prefix and breaks containment). |
| 84 | +theorem isPrefix_dropLast |
| 85 | + (xs ys : Path) |
| 86 | + (hp : xs <+: ys) (hne : ys ≠ xs) : |
| 87 | + xs <+: ys.dropLast := by |
| 88 | + -- Obtain the witness suffix: ys = xs ++ tail |
| 89 | + obtain ⟨tail, htail⟩ := hp |
| 90 | + -- tail must be non-empty (if it were [], then ys = xs ++ [] = xs, |
| 91 | + -- contradicting hne). |
| 92 | + cases tail with |
| 93 | + | nil => |
| 94 | + exfalso; apply hne |
| 95 | + simpa using htail.symm |
| 96 | + | cons t ts => |
| 97 | + -- ys = xs ++ (t :: ts); dropLast strips the trailing element. |
| 98 | + -- ys.dropLast = (xs ++ (t :: ts)).dropLast = xs ++ (t :: ts).dropLast |
| 99 | + -- (since t :: ts is non-empty), so xs is still a prefix. |
| 100 | + refine ⟨(t :: ts).dropLast, ?_⟩ |
| 101 | + subst htail |
| 102 | + rw [List.dropLast_append_of_ne_nil _ (List.cons_ne_nil t ts)] |
| 103 | + |
| 104 | +-- --------------------------------------------------------------------- |
| 105 | +-- Per-step invariant |
| 106 | +-- --------------------------------------------------------------------- |
| 107 | + |
| 108 | +-- Applying one component preserves the root-prefix invariant. |
| 109 | +theorem applyComponent_preserves_root_prefix |
| 110 | + (root normalized : Path) (c : Component) |
| 111 | + (h : root <+: normalized) : |
| 112 | + root <+: applyComponent root normalized c := by |
| 113 | + cases c with |
| 114 | + | curDir => |
| 115 | + unfold applyComponent |
| 116 | + exact h |
| 117 | + | normal s => |
| 118 | + unfold applyComponent |
| 119 | + exact isPrefix_append_right root normalized [s] h |
| 120 | + | parentDir => |
| 121 | + unfold applyComponent |
| 122 | + by_cases hcond : root <+: normalized ∧ normalized ≠ root |
| 123 | + · simp [hcond] |
| 124 | + obtain ⟨hpre, hne⟩ := hcond |
| 125 | + exact isPrefix_dropLast root normalized hpre hne |
| 126 | + · simp [hcond]; exact h |
| 127 | + |
| 128 | +-- --------------------------------------------------------------------- |
| 129 | +-- foldl invariant |
| 130 | +-- --------------------------------------------------------------------- |
| 131 | + |
| 132 | +-- foldl preserves the root-prefix invariant for any initial accumulator |
| 133 | +-- that already starts with root. |
| 134 | +theorem normalizeRaw_acc_preserves_root_prefix |
| 135 | + (root : Path) : |
| 136 | + ∀ (raw : List Component) (acc : Path), |
| 137 | + root <+: acc → |
| 138 | + root <+: raw.foldl (applyComponent root) acc := by |
| 139 | + intro raw |
| 140 | + induction raw with |
| 141 | + | nil => |
| 142 | + intro acc h; exact h |
| 143 | + | cons c rest ih => |
| 144 | + intro acc h |
| 145 | + simp only [List.foldl] |
| 146 | + apply ih |
| 147 | + exact applyComponent_preserves_root_prefix root acc c h |
| 148 | + |
| 149 | +-- The intermediate result is always within root. |
| 150 | +theorem normalizeRaw_within_root (root : Path) (raw : List Component) : |
| 151 | + root <+: normalizeRaw root raw := by |
| 152 | + unfold normalizeRaw |
| 153 | + exact normalizeRaw_acc_preserves_root_prefix root raw root (List.prefix_refl root) |
| 154 | + |
| 155 | +-- --------------------------------------------------------------------- |
| 156 | +-- Headline theorem (frontier A-12) |
| 157 | +-- --------------------------------------------------------------------- |
| 158 | + |
| 159 | +-- Path-traversal containment: `normalizePath root raw` is always a |
| 160 | +-- descendant of `root`, regardless of `..` / `.` / normal components |
| 161 | +-- in `raw`. This is the regression-guard for the 2026-02-12 CVE-class |
| 162 | +-- audit fix. |
| 163 | +theorem path_traversal_containment |
| 164 | + (root : Path) (raw : List Component) : |
| 165 | + root <+: normalizePath root raw := by |
| 166 | + unfold normalizePath |
| 167 | + by_cases h : root <+: normalizeRaw root raw |
| 168 | + · simp [h] |
| 169 | + · simp [h] |
| 170 | + |
| 171 | +-- Corollary: the final clamp never fires (the unfolded definition |
| 172 | +-- always lands on `normalizeRaw root raw`, never on the fallback |
| 173 | +-- `root`). Useful as a regression-guard against changes to |
| 174 | +-- `applyComponent` that would silently break the invariant. |
| 175 | +theorem normalizePath_eq_normalizeRaw |
| 176 | + (root : Path) (raw : List Component) : |
| 177 | + normalizePath root raw = normalizeRaw root raw := by |
| 178 | + unfold normalizePath |
| 179 | + have h : root <+: normalizeRaw root raw := normalizeRaw_within_root root raw |
| 180 | + simp [h] |
| 181 | + |
| 182 | +end PathTraversal |
0 commit comments