Skip to content

docs(expr): warn that is_relative_to is a lexical prefix test, not containment - #367

Open
seant-aws wants to merge 1 commit into
OpenJobDescription:mainfrom
seant-aws:docs/is-relative-to-warning
Open

docs(expr): warn that is_relative_to is a lexical prefix test, not containment#367
seant-aws wants to merge 1 commit into
OpenJobDescription:mainfrom
seant-aws:docs/is-relative-to-warning

Conversation

@seant-aws

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

is_relative_to is 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 gets true for /allowed/../etc/passwd because .. 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.md after the path methods table:

  • Changed table description from "Check prefix relationship" → "Lexical prefix test (see note below)"
  • States that .. segments are ordinary components, with cross-reference to path-parse.md
  • Shows the traversal example
  • Provides the safe idiom: p.is_relative_to(base) and not ("..") in p.parts
  • Notes that .parts is a property, not a function

What 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_to behavior is unchanged and the safe idiom works:

  • path("/allowed/../etc/passwd").is_relative_to(path("/allowed"))true
  • p.is_relative_to(base) and not ("..") in p.partsfalse on traversal payloads

All 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.

@seant-aws
seant-aws marked this pull request as ready for review September 3, 2026 23:23
@seant-aws
seant-aws requested a review from a team as a code owner September 3, 2026 23:23
…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>
@seant-aws
seant-aws force-pushed the docs/is-relative-to-warning branch from dc5ab9e to f0fb160 Compare September 3, 2026 23:35
> `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.

> 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).

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.

@leongdl leongdl left a comment

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.

Do we need to update the OpenJD Spec?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants