Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion specs/expr/path-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,18 @@ Path-related operations in the expression language:
| `with_number(n)` | `(path, int) -> path` / `(string, int) -> path` | Append frame number |
| `as_posix()` | `(path) -> string` | Convert to POSIX string |
| `is_absolute()` | `(path) -> bool` | Check if path is absolute |
| `is_relative_to(other)` | `(path, path) -> bool` | Check prefix relationship |
| `is_relative_to(other)` | `(path, path) -> bool` | Lexical prefix test (see note below) |
| `relative_to(other)` | `(path, path) -> path` | Compute relative path |

> **`is_relative_to` is a lexical prefix test, not a containment
> check.** `..` segments are ordinary components (see
> [`path-parse.md`](path-parse.md#normalization)), so
> `path("/allowed/../etc/passwd").is_relative_to(path("/allowed"))`
> returns `true`. This matches `PurePosixPath.is_relative_to()`.
> To reject traversal, combine with a `..` check:
> `p.is_relative_to(base) and not ("..") in p.parts`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recommended guard reads as though the parentheses change the grouping, but they do not: not ("..") in p.parts parses as not (".." in p.parts) — the parenthesized ("..") is just the left operand of in. It happens to be the intended semantics, yet a reader scanning a security-hardening snippet is likely to misread it as (not "..") in p.parts.

Since the expression language supports not in (__not_contains__, see function-library.md operator table), the unambiguous form is:

p.is_relative_to(base) and ".." not in p.parts

Worth using the idiomatic spelling here specifically because this snippet is the one people will copy into a path-traversal check.

> (note: `.parts` is a property, not a function).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .. guard is presented as the general remedy, but it does not hold for URI paths, which is_relative_to also accepts ((path, path) -> bool is registered for URI values, and tests/integration/test_paths.rs covers s3:// cases). For a URI, .parts routes to uri_path::parts, which by design leaves every byte exactly as supplied — see the "Percent-encoded segments" bullet a few lines above at path-mapping.md:123. So:

  • path("s3://bucket/allowed/%2e%2e/secret").parts yields ["s3://bucket", "allowed", "%2e%2e", "secret"] — no literal ".." component, so the guard passes while an S3 client that normalizes percent-encoding before resolving may still escape allowed/.

Since this note exists to steer people away from an unsafe assumption, it would help to scope it explicitly (e.g. "for filesystem paths; for URI values .parts is un-decoded, so a .. component may appear percent-encoded and this check will not see it"), rather than leaving the snippet to read as sufficient for all path values.


> **URI paths in path methods.** The path properties and the
> `with_*` methods all detect URI inputs (`uri_path::is_uri`) and
> route to `uri_path::*` so the result preserves URI grammar —
Expand Down
Loading