fix(security): prevent path traversal in file upload endpoints#6804
fix(security): prevent path traversal in file upload endpoints#6804JasonOA888 wants to merge 4 commits intoBasedHardware:mainfrom
Conversation
Both /v1/files and /v2/files used user-provided filename directly as the
local file path (Path(f"{file.filename}")), allowing path traversal attacks
(e.g. filename="../../tmp/malicious").
Fix: use Path(filename).name to strip directory components and prepend a
UUID to avoid collisions. Also wrap in try/finally to guarantee temp file
cleanup even when FileChatTool.upload() raises.
Greptile SummaryThis PR correctly fixes a path traversal vulnerability in the
Confidence Score: 4/5The core path-traversal fix is correct, but a P1 regression (None filename → TypeError) should be addressed before merging. The path traversal vulnerability is properly fixed and try/finally cleanup is a clear improvement. However, the None-filename TypeError regression is a P1 that would produce 500 errors for any upload without a filename in Content-Disposition, which is a real (if narrow) crash path introduced by this PR. backend/routers/chat.py — lines 830 and 889 (None filename guard), lines 831 and 890 (CWD vs temp dir) Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant FastAPI as FastAPI (/v1 or /v2 files)
participant FS as Filesystem
participant FileChatTool
Client->>FastAPI: POST /v2/files (UploadFile)
Note over FastAPI: safe_suffix = Path(file.filename).name
Note over FastAPI: ⚠️ Raises TypeError if file.filename is None
FastAPI->>FS: Write UUID_safe_suffix to CWD (relative path)
FastAPI->>FileChatTool: upload(temp_file)
alt upload succeeds
FileChatTool-->>FastAPI: result dict
FastAPI->>FS: unlink(temp_file) [finally]
FastAPI-->>Client: 200 FileChat[]
else upload raises
FastAPI->>FS: unlink(temp_file) [finally — guaranteed]
FastAPI-->>Client: 500
end
Reviews (1): Last reviewed commit: "fix(security): prevent path traversal in..." | Re-trigger Greptile |
|
|
||
| result = FileChatTool.upload(temp_file) | ||
| # Use a UUID-based temp file name to prevent path traversal via user-controlled filename | ||
| safe_suffix = Path(file.filename).name # strip any directory components |
There was a problem hiding this comment.
file.filename can be None — raises TypeError before try block
FastAPI's UploadFile.filename is typed Optional[str]. When it is None (e.g. a form upload with no Content-Disposition filename), Path(None) raises TypeError at this line — before the try block is entered, so no cleanup is needed but the caller gets an unhandled 500. The old code accidentally avoided this by using f"{file.filename}" which coerces None to the string "None". Add a guard:
| safe_suffix = Path(file.filename).name # strip any directory components | |
| safe_suffix = Path(file.filename).name if file.filename else "upload" # strip any directory components |
The same fix is needed at the identical line in the /v1/files endpoint (line 889).
| result = FileChatTool.upload(temp_file) | ||
| # Use a UUID-based temp file name to prevent path traversal via user-controlled filename | ||
| safe_suffix = Path(file.filename).name # strip any directory components | ||
| temp_file = Path(f"{uuid.uuid4().hex}_{safe_suffix}") |
There was a problem hiding this comment.
Temp files written to CWD, not a system temp directory
Path(f"{uuid.uuid4().hex}_{safe_suffix}") is a relative path, so the file lands in the server process's current working directory. If the CWD is read-only, on a root filesystem, or simply not intended for ephemeral files, this will either fail or litter the CWD. Prefer writing to the system temp dir:
| temp_file = Path(f"{uuid.uuid4().hex}_{safe_suffix}") | |
| temp_file = Path(tempfile.gettempdir()) / f"{uuid.uuid4().hex}_{safe_suffix}" |
This requires adding import tempfile at the top of the file. The same change applies to the identical line in the /v1/files endpoint (line 890).
- Guard file.filename against None (UploadFile.filename is Optional[str]) - Write temp files to system temp directory instead of CWD
|
@JasonOA888 tests ? |
|
@beastoin Added tests in
All 13 pass locally. Pushed to this branch. |
|
@JasonOA888 unit tests only, right? can we have higher-level of tests to make sure the pr works as expected and does not break anything? |
|
@beastoin Added endpoint-level tests in
Combined with the 13 unit tests from the previous commit, that's 26 tests total. All 26 pass locally. |
Summary
Both
/v1/filesand/v2/filesused the user-providedfilenamedirectly as the local file path:This allows path traversal — a crafted filename like
../../tmp/maliciousor an absolute path like/etc/cron.d/xwould write to arbitrary locations on the server. The file is normally cleaned up byunlink(), but ifFileChatTool.upload()throws, the temp file persists on disk indefinitely.Changes
Path(file.filename).namestrips directory components, then prepend UUID for uniquenesstry/finallyso temp files are always deletedBefore
After
Applies to both
/v1/filesand/v2/filesendpoints.