docs(expr): warn that is_relative_to is a lexical prefix test, not containment - #367
docs(expr): warn that is_relative_to is a lexical prefix test, not containment#367seant-aws wants to merge 1 commit into
Conversation
…ntainment is_relative_to treats '..' as an ordinary component, matching Python's PurePosixPath.is_relative_to(). Document that it should not be used alone to confine untrusted paths, and provide the safe idiom. Signed-off-by: Sean Tang <171081544+seant-aws@users.noreply.github.com>
dc5ab9e to
f0fb160
Compare
| > `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` |
There was a problem hiding this comment.
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.
| > 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` | ||
| > (note: `.parts` is a property, not a function). |
There was a problem hiding this comment.
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").partsyields["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 escapeallowed/.
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.
leongdl
left a comment
There was a problem hiding this comment.
Do we need to update the OpenJD Spec?
What was the problem/requirement? (What/Why)
is_relative_tois documented as "Check prefix relationship" — accurate, but the name and description read like a containment check. A template author using it to confine an untrusted path getstruefor/allowed/../etc/passwdbecause..is an ordinary component in the lexical path model. Nothing in the spec or crate docs warns about this.The behavior is correct and matches Python's
PurePosixPath.is_relative_to(). This is a documentation gap, not a code defect.What was the solution? (How)
Added a warning blockquote to
specs/expr/path-mapping.mdafter the path methods table:..segments are ordinary components, with cross-reference topath-parse.mdp.is_relative_to(base) and not ("..") in p.parts.partsis a property, not a functionWhat is the impact of this change?
Docs-only. No code changes, no behavior changes.
How was this change tested?
Verified through the CLI that
is_relative_tobehavior is unchanged and the safe idiom works:path("/allowed/../etc/passwd").is_relative_to(path("/allowed"))→truep.is_relative_to(base) and not ("..") in p.parts→falseon traversal payloadsAll 3,343 openjd-expr unit tests pass. All 352 EXPR conformance tests pass.
Was this change documented?
This PR is the documentation change.
Is this a breaking change?
No.