Skip to content

fix: contain model deletion/download paths against path traversal (#2267) - #2273

Open
DeathSurfing wants to merge 1 commit into
exo-explore:mainfrom
DeathSurfing:contrib/2267-model-delete-path-traversal
Open

fix: contain model deletion/download paths against path traversal (#2267)#2273
DeathSurfing wants to merge 1 commit into
exo-explore:mainfrom
DeathSurfing:contrib/2267-model-delete-path-traversal

Conversation

@DeathSurfing

Copy link
Copy Markdown

Summary

Fixes #2267: a path traversal in the model deletion flow. DELETE /download/{node_id}/{model_id} accepts model_id values like .. (delivered URL-encoded as %2e%2e), which reach delete_model() and shutil.rmtree() a path derived from attacker input with no containment check. ModelId.normalize() only replaces / with --, so a bare .. passes through unchanged and models_dir / ".." resolves to the parent of the model directory — on a default install, the whole exo data home (~/.local/share/exo), including downloaded weights, the event log, and custom model cards. A bare . likewise resolves to the models root itself.

Changes

  • validate_model_path_contained() (download_utils.py): new helper that resolves root / model_id.normalize() and raises ValueError if it escapes the root or resolves to the root itself (. / empty-id protection). Mirrors the containment pattern already used for trace files (Path.resolve().is_relative_to(...)).
  • delete_model(): validates against each writable models dir and the cache root before any rmtree.
  • build_model_path(): validates before the existing-model lookup, so a traversal-resolved path (e.g. the parent dir, which can look like a complete model) can never be returned or used as a download target.
  • ensure_cache_dir(): validates before makedirs, so no stray directory is created outside the cache root.
  • DELETE /download (api/main.py): rejects ids whose normalized form is . / .. with HTTP 400 before a command is sent.
  • DownloadCoordinator._delete_download: catches ValueError/OSError from delete_model and logs, so one bad id cannot kill the command-processing loop.

Verification

  • uv run pytest src/exo/download/tests/77 passed (10 new tests: traversal/dot-id rejection for delete and download-side helpers, legit-id positive controls, and the regression where the existing-model lookup would return a traversal path).
  • uv run ruff check and uv run ruff format --check → clean on all changed files (treefmt uses ruff-format for Python).
  • uv run basedpyright → no errors/warnings in any changed file.
  • Manual repro driving the real delete_model()/build_model_path()/ensure_cache_dir() against a sandboxed tree: .. and . raise ValueError, the victim parent directory survives, no stray dirs are created, and legitimate ids still work.

Test plan

  1. pytest suite above.
  2. Manual: curl -X DELETE http://<node>:52415/download/<node_id>/%2e%2e400 Invalid model id: ..; deleting a real downloaded model still works.

Closes #2267

- validate_model_path_contained() rejects ids whose normalized path resolves
  outside (or to) a models root, mirroring the existing trace-path pattern
- delete_model(), build_model_path(), ensure_cache_dir() validate before any
  rmtree/makedirs; DELETE /download rejects "."/".." with HTTP 400; the
  download coordinator survives invalid ids without losing its command loop
- tests cover traversal rejection for the delete/download helpers, including
  the case where an existing-model lookup would return a traversal path

Closes exo-explore#2267
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.

[BUG] Path traversal in DELETE /download/{node_id}/{model_id} allows recursive deletion outside the model directory

1 participant