microsoft / microsoft/litebox

path: normalized_components emits ".." for absolute paths (POSIX non-conformance; disagrees with fs::resolver)

Open
#1,099 1 comment 0 reactions 1 assignee View on GitHub

@jaybosamiya-ms is already working on this.

Since Jul 29, 2026.

Dominant language
Rust
Stars
2.7k
Forks
144
Avg merge
12h 21m
Merged PRs (30d)
146

Description

## Summary

`Arg::normalized_components` ([`litebox/src/path.rs` L67-L90](https://github.com/microsoft/litebox/blob/6a03ec80f065d2a66b937bde3d6f0708d282ca27/litebox/src/path.rs#L67-L90))
emits literal `..` components for absolute paths whose `..` count exceeds the
number of preceding normal components. POSIX requires that `..` be clamped at
the root — [POSIX.1-2017 §4.13 Pathname Resolution](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_13)
states that dot-dot in the root directory refers to the root directory itself,
so `/foo/../..` is equivalent to `/`.

The same crate already contains a second resolver that handles this correctly,
so the two disagree on the same input.

## Observed vs. expected

| Input | current output | POSIX-expected |
|---|---|---|
| `/foo/../..` | `["", ".."]` | `[""]` |
| `/..` | `["", ".."]` | `[""]` |
| `/../../host_secret` | `["", "..", "..", "host_secret"]` | `["", "host_secret"]` |
| `/a/../..` | `["", ".."]` | `[""]` |
| `/../x` | `["", "..", "x"]` | `["", "x"]` |
| `//..//..` | `["", "..", ".."]` | `[""]` |
| `/a/b/../../c` | `["", "c"]` | `["", "c"]` ✔ |
| `/etc/passwd` | `["", "etc", "passwd"]` | ✔ |

## Root cause

The algorithm walks components right-to-left via `rsplit('/')`, counting `..`
into `parent_count` and consuming normal components against it. Any leftover
count is emitted directly:

```rust
rev_norm_components.extend(core::iter::repeat_n("..", parent_count));
if self.as_rust_str()?.starts_with('/') {
rev_norm_components.push("");
}
```

For an absolute path the leftover `parent_count` should be dropped.

## Internal inconsistency

[`litebox/src/fs/resolver.rs` L84-L102](https://github.com/microsoft/litebox/blob/6a03ec80f065d2a66b937bde3d6f0708d282ca27/litebox/src/fs/resolver.rs#L84-L102)
resolves `..` with `let _ = components.pop();`. Popping an empty vector is a
no-op, so it clamps at the root as POSIX requires. Two resolvers in the same
crate therefore produce different results for the same input.

## Where it surfaces

[`walk_to` in `litebox/src/fs/nine_p/mod.rs` L343-L355](https://github.com/microsoft/litebox/blob/6a03ec80f065d2a66b937bde3d6f0708d282ca27/litebox/src/fs/nine_p/mod.rs#L343-L355)
collects `normalized_components()` and passes the result verbatim to
`client.walk(&self.root.1, &components)`, rooted at the exported root fid. So
opening `/../../x` emits a `Twalk` for `["", "..", "..", "x"]` against the
export root, and correct behaviour depends on the 9P server clamping `..`.
Conventional servers (qemu virtfs, diod) do clamp, so this is a robustness /
hardening concern rather than a demonstrated escape — see the note below.

Secondary: every absolute path also produces an empty-string first wname
(`/etc/passwd` → `["", "etc", "passwd"]`, `/` → `[""]`), which is passed to
`client.walk`. An empty `wname` is not a valid 9P path element
([walk(5)](https://man.cat-v.org/plan_9/5/walk)).

## Reproduction

The function is self-contained; transcribing it verbatim reproduces the table
above. Full runnable repro:

```rust
fn normalized_components(s: &str) -> Vec<&str> {
let mut parent_count = 0;
let mut rev = s.rsplit('/')
.filter(|&c| match c {
"" | "." => false,
".." => { parent_count += 1; false }
_ if parent_count > 0 => { parent_count -= 1; false }
_ => true,
})
.collect::>();
rev.extend(core::iter::repeat_n("..", parent_count));
if s.starts_with('/') { rev.push(""); }
rev.into_iter().rev().collect()
}

fn main() {
for c in ["/foo/../..", "/..", "/../../host_secret", "/a/b/../../c", "/etc/passwd"] {
println!("{c:<20} -> {:?}", normalized_components(c));
}
}
```

Verified against `6a03ec80f065d2a66b937bde3d6f0708d282ca27` (upstream `main`
HEAD at time of writing) with rustc 1.96.0. `path.rs` has had no functional
change since 2025, so this is long-standing rather than a recent regression.

## Suggested fix

When the input is absolute, drop the leftover `parent_count` rather than
emitting it:

```rust
rev_norm_components.extend(core::iter::repeat_n(
"..",
if self.as_rust_str()?.starts_with('/') { 0 } else { parent_count },
));
```

Suggested tests: `/..`, `/foo/../..`, `/../../x`, `//..//..`, `/a/../..`.

Longer term, consolidating onto the `resolver.rs` semantics would remove the
divergence entirely.

## Note on how this is being reported

I first sent this to MSRC (VULN-205467) since `SECURITY.md` directs security
issues there. MSRC reviewed it and determined it does not meet Microsoft's
definition of a security vulnerability — the trust boundary is enforced at the
9P server and is not demonstrably crossed by the client normalizer alone — and
encouraged filing it here as a hardening fix. Posting publicly on that basis.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.