Skip to content

fix(designer): confine file-content API to loaded project roots - #2301

Open
fra-shipper wants to merge 2 commits into
TEN-framework:mainfrom
fra-shipper:fix/designer-file-content-path-traversal
Open

fra-shipper wants to merge 2 commits into
TEN-framework:mainfrom
fra-shipper:fix/designer-file-content-path-traversal

Conversation

@fra-shipper

Copy link
Copy Markdown

Summary

Refs #2187 -- addresses the path-confinement half of the report. See "Not addressed" below for what this PR does not cover.

get_file_content_endpoint and save_file_content_endpoint in core/src/ten_manager/src/designer/file_content/mod.rs read and wrote a client-supplied file_path with fs::read_to_string / fs::write and no validation. The designer server binds 0.0.0.0:49483 with any-origin CORS and no authentication middleware, so an unauthenticated caller could read or write any file the tman process has access to (e.g. /etc/passwd, ~/.ssh/authorized_keys) via an absolute path or a .. traversal, matching the PoC in the issue.

Fix

  • Added ensure_within_allowed_roots(), which canonicalizes the requested path and rejects it unless it resolves inside one of the app base directories already tracked in DesignerState.pkgs_cache. Both endpoints return 403 Forbidden when a path is rejected outside the allowed roots; 400 Bad Request is preserved for the pre-existing malformed-path/I/O-error cases (a PathRejection enum keeps a merely non-existent path from being bucketed with a confinement violation).
  • The write endpoint previously called fs::create_dir_all(parent) on the raw, attacker-supplied path before any validation ran, so a request could make it create directories outside every loaded root even though the write itself was rejected afterwards. Added resolve_prospective_path(), which walks up to the nearest existing ancestor of the target parent, canonicalizes only that, and lexically re-applies the not-yet-created suffix on top -- all before create_dir_all runs. This also closes a related escape: create_dir_all re-resolves any .. left in its argument against directories it creates along the way, so validating only the already-created parent (the naive fix) does not stop a crafted path like a/b/../../../etc (where only a exists and is an allowed root) from creating a directory outside of a.
  • An empty pkgs_cache (no app loaded yet) is treated as "no allowed roots" and rejects every request rather than allowing or denying by an unvalidated default.

Not addressed

The issue's "Expected behavior" also asks for authentication on the designer API and binding to loopback by default instead of 0.0.0.0. Neither is in this diff; it is scoped to path confinement. An unauthenticated caller can still reach these endpoints -- the fix limits what such a caller can read or write to files inside a currently loaded project root, instead of the whole filesystem.

Testing

Added 9 unit tests under core/src/ten_manager/tests/test_case/designer/file_content/mod.rs, following the existing pattern in designer/dir_list: in-root read/write round trip, read/write of an absolute path outside any loaded root, a .. traversal that resolves outside the root (both endpoints), a request made with no project root loaded, and a save whose target parent directory chain does not exist yet on disk (the case that exercises the create_dir_all-ordering bug -- the other tests' parents already existed, so create_dir_all was a no-op there).

I don't have a Rust toolchain available in my normal working environment for this repo (ten_manager's dependency tree includes clingo-sys, which compiles the Clingo ASP-solver C++ library from source via cmake, and cmake/bison/re2c weren't installed). I later got a toolchain working long enough to build and run this crate's tests directly:

$ cargo test -p ten_manager file_content
running 9 tests
test test_case::designer::file_content::tests::test_get_file_content_missing_file_in_root_is_bad_request ... ok
test test_case::designer::file_content::tests::test_get_file_content_with_no_loaded_root_is_rejected ... ok
test test_case::designer::file_content::tests::test_save_file_content_outside_root_with_nonexistent_parent_chain_is_rejected ... ok
test test_case::designer::file_content::tests::test_save_file_content_within_root_succeeds ... ok
test test_case::designer::file_content::tests::test_save_file_content_path_traversal_is_rejected ... ok
test test_case::designer::file_content::tests::test_get_file_content_within_root_succeeds ... ok
test test_case::designer::file_content::tests::test_get_file_content_outside_root_is_rejected ... ok
test test_case::designer::file_content::tests::test_save_file_content_outside_root_is_rejected ... ok
test test_case::designer::file_content::tests::test_get_file_content_path_traversal_is_rejected ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 279 filtered out; finished in 0.01s

To confirm the new tests actually exercise this fix (not just compile), I reverted only file_content/mod.rs to its pre-fix state (test file untouched) and re-ran the same command: 6 of the 9 tests failed with assertion left == right failed / left: 200 / right: 403 against the endpoints that should have rejected the request. I then restored the fix and re-ran to the same 9/9 passing result shown above. cargo check -p ten_manager also finished cleanly with 0 warnings.

get_file_content_endpoint and save_file_content_endpoint in
file_content/mod.rs read and write a client-supplied file_path with
fs::read_to_string / fs::write and no validation. The designer server
binds 0.0.0.0:49483 with any-origin CORS and no authentication, so an
unauthenticated caller could read or write any file the tman process
has access to (e.g. /etc/passwd, ~/.ssh/authorized_keys) via an
absolute path or a `..` traversal.

Add ensure_within_allowed_roots(), which canonicalizes the requested
path and rejects it unless it resolves inside one of the app base
directories already tracked in DesignerState.pkgs_cache. The read
endpoint validates the target file directly; the write endpoint
validates the (now-created) parent directory first, since
fs::canonicalize requires the path to exist, then joins the file
name back on. Both endpoints return 403 Forbidden on a rejected path,
and 400 Bad Request is preserved for pre-existing malformed-path and
I/O error cases. An empty pkgs_cache (no app loaded yet) is treated
as "no allowed roots" and rejects every request.

Adding authentication and binding to loopback by default are left as
follow-ups; this change only closes the path-confinement gap.

Adds unit tests under tests/test_case/designer/file_content/ covering:
an in-root read/write round trip, a read/write of an absolute path
outside any loaded root, a `..` traversal that resolves outside the
root, and requests made with no project root loaded.
save_file_content_endpoint called fs::create_dir_all(parent) on the raw,
attacker-supplied file_path before ensure_within_allowed_roots ever ran,
so an unauthenticated caller could make it mkdir -p an arbitrary
directory chain outside every loaded project root before the write
itself was correctly rejected with 403. Add resolve_prospective_path,
which walks up to the nearest existing ancestor of the target parent,
canonicalizes it, and re-applies the remaining not-yet-created
components on top before any directory is created, and confine the
write to that validated path. This also closes a related escape: since
create_dir_all re-resolves any `..` left in its argument against
directories it creates along the way, validating only the final,
already-created parent (as before) does not stop a crafted path like
`a/b/../../../etc` from making it create a directory outside of `a`.

Also split PathRejection into Unresolvable vs OutsideAllowedRoots in
ensure_within_allowed_roots, so get_file_content_endpoint can tell a
merely non-existent path (400, matching pre-PR behavior) apart from
one that resolves outside every loaded root (403), instead of bucketing
every canonicalize failure into a security rejection.

Adds a save test where the out-of-root target's parent chain does not
exist yet (the prior tests' parents already existed, so create_dir_all
was a no-op and never exercised the ordering bug), asserting no
directory is created under the outside root, and a get test for the
400-vs-403 distinction.
@fra-shipper
fra-shipper requested a review from halajohn as a code owner August 31, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant