Fix case-run path traversal#431
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses a path traversal vulnerability in DataFile-related delete/download routes by validating user-controlled path segments relative to the intended case/run subdirectory (instead of only validating against the broad Config.DATA_STORAGE root).
Changes:
- Added a
_safe_child_path(...)helper and applied it todeleteCaseRunand multiple download routes to prevent sibling-case traversal. - Added
PermissionErrorhandling to return400 Invalid path.for invalid/traversal attempts. - Added regression tests covering both allowed deletion and blocked traversal attempts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
API/Routes/DataFile/DataFileRoute.py |
Introduces narrower path validation for delete/download endpoints via _safe_child_path and maps invalid paths to HTTP 400. |
tests/test_datafile_guards.py |
Adds regression tests proving traversal to sibling cases is blocked and normal deletion still works. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
|
|
||
| def _safe_child_path(base_dir, *parts): | ||
| relative_path = os.path.join(*[part for part in parts if part not in (None, "")]) |
There was a problem hiding this comment.
_safe_child_path can raise a TypeError when all provided parts are None/"" because os.path.join() is called with an empty argument list. This can be triggered by an empty string input (e.g., caserunname == ""), resulting in a 500 instead of a controlled 400. Consider explicitly handling the “no effective parts” case (e.g., raise PermissionError / return a 400) or ensure callers validate non-empty segments before calling this helper.
| relative_path = os.path.join(*[part for part in parts if part not in (None, "")]) | |
| effective_parts = [part for part in parts if part not in (None, "")] | |
| if not effective_parts: | |
| raise PermissionError("At least one non-empty path segment is required") | |
| relative_path = os.path.join(*effective_parts) |
| @@ -77,8 +82,8 @@ def deleteCaseRun(): | |||
| if not casename: | |||
| return jsonify({'message': 'No model selected.', 'status_code': 'error'}), 400 | |||
There was a problem hiding this comment.
deleteCaseRun does not validate that caserunname is non-empty. Because validate_json_fields only checks key presence, a request with caserunname: "" will reach _safe_child_path(case_results_dir, caserunname) and currently produce a server error (TypeError from os.path.join) instead of returning a 400. Add an explicit check for an empty caserunname (consistent with the download routes) or make _safe_child_path robust to empty segments.
| return jsonify({'message': 'No model selected.', 'status_code': 'error'}), 400 | |
| return jsonify({'message': 'No model selected.', 'status_code': 'error'}), 400 | |
| if not caserunname: | |
| return jsonify({'message': 'No case run selected.', 'status_code': 'error'}), 400 |
| return jsonify(response), 200 | ||
| except PermissionError: | ||
| return jsonify({'message': 'Invalid path.', 'status_code': 'error'}), 400 |
There was a problem hiding this comment.
Catching PermissionError and returning “Invalid path.” will also convert real filesystem permission failures (e.g., inability to delete due to OS ACLs/locks) into a 400 that looks like a traversal attempt. If you want to preserve debuggability, consider only mapping PermissionError originating from Config.validate_path/_safe_child_path to 400 and letting deletion-time permission errors surface as 5xx/appropriate error codes.
|
Thanks for this. We're prioritizing other work right now, so converting this to draft for the time being. We'll revisit when we're ready to pick these up. |
Existing related work reviewed
UploadRoute.py.backupCase, but they focus on archive entry layout/relative paths.deleteCaseRunor DataFile download traversal throughcaserunname/fileinAPI/Routes/DataFile/DataFileRoute.py.Overlap assessment
deleteCaseRun,downloadDataFile,downloadFile,downloadCSVFile, anddownloadResultsFileinDataFileRoute.py. The related PRs focus on ZIP extraction or backup archive generation inUploadRoute.py, so they do not cover sibling case traversal through case-run/download parameters.Why this PR should proceed
caserunnameorfilevalues could escape the selected case/run directory while still staying underConfig.DATA_STORAGE.400 Invalid path.Summary
_safe_child_path(...), used it to validate nested DataFile delete/download paths against the intended child directory, addedPermissionErrorhandling for invalid delete paths, and added regression tests intests/test_datafile_guards.py.DATA_STORAGEroot, which could still allow traversal from one case/run path into a sibling case under the same storage root.Validation
collected 3 items
tests\test_datafile_guards.py ... [100%]
3 passed, 1 warning in 0.63s
The warning was only a local pytest cache permission warning for .pytest_cache; the tests passed.