fix(terminology): prevent path traversal in Excel upload#1284
Open
sebastiondev wants to merge 1 commit into
Open
fix(terminology): prevent path traversal in Excel upload#1284sebastiondev wants to merge 1 commit into
sebastiondev wants to merge 1 commit into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The terminology Excel upload endpoint (
POST /terminology/uploadExcel, handlerupload_excelinbackend/apps/terminology/api/terminology.py) writes an uploaded file to disk using the client-suppliedfile.filenamewithout stripping directory components. An authenticated workspace admin can send a multipart upload whose filename contains../segments and cause the server to write attacker-controlled bytes outside the intendedEXCEL_PATHdirectory — a path traversal / arbitrary file write (CWE-22).Vulnerable code (before)
file.filenameis fully controlled by the client.split('.')[0]does not remove path separators, so a filename such as../../etc/cron.d/pwn.xlsxproduces asave_pathlike<EXCEL_PATH>/../../etc/cron.d/pwn_<hash>.xlsx, whichos.path.joinhappily resolves outsideEXCEL_PATH. The file body is then written verbatim.Fix
Two-layer defense applied to both the primary save and the error-report save further down the same handler:
os.path.basename(file.filename)strips any directory components from the client string before it is used to build the on-disk name.os.path.realpathis compared againstos.path.realpath(path)viaos.path.commonpath. If the resolved destination escapes the base directory (e.g. via a symlink underpath, or any other bypass we missed), the request is rejected with HTTP 400.The extension is now derived with
os.path.splitextinstead ofsplit('.')[1], which also fixes a latentIndexErroron filenames without a dot and preserves multi-dot names correctly.Diff is 12 additions / 4 deletions in a single file.
Proof of concept
Against a running SQLBot instance, authenticated as a workspace admin:
Before the patch: a file matching
/tmp/sqlbot_pwn_*.xlsxappears on the host filesystem, outside the terminology excel directory. On real deployments the traversal target could be a writable location such as/etc/cron.d/, an application source file, or a static asset — turning workspace-admin into host-level arbitrary write, and in many environments into RCE.After the patch: the basename strip reduces the filename to
sqlbot_pwn.xlsxand the containment check rejects anything that still resolves outsideEXCEL_PATH.Security analysis
@router.post("/uploadExcel")bound toupload_excel.open(save_path, "wb").write(await file.read())and the laterdf.to_excel(save_error_path, ...).file.filenamefrom theUploadFile— attacker-controlled.Adversarial review
Before submitting we tried to disprove this. Candidates considered:
UploadFile.filename." It does not —filenameis passed through from the multipartContent-Dispositionheader verbatim; this is documented and easy to reproduce locally.os.path.joinwill refuse traversal." It won't —os.path.join('/a/b', '../../etc/x')returns/a/b/../../etc/xandopen()follows it.split('.')[0]trims the traversal." Only the extension side; the path prefix survives.None of the mitigations hold, so the finding is exploitable as described.
Testing
save_pathandsave_error_path) are covered by the basename + realpath/commonpath check.os.path.basename('../../etc/cron.d/x.xlsx')returnsx.xlsx, and thatos.path.commonpathyields a mismatching prefix for escapes, which the code treats as invalid.os.path.splitextpreserves the extension for normal cases (foo.xlsx→.xlsx) and no longer raises on filenames without a dot.Discovered by the Sebastion AI GitHub App.