Skip to content

fix(terminology): prevent path traversal in Excel upload#1284

Open
sebastiondev wants to merge 1 commit into
dataease:mainfrom
sebastiondev:fix/cwe22-terminology-upload-8809
Open

fix(terminology): prevent path traversal in Excel upload#1284
sebastiondev wants to merge 1 commit into
dataease:mainfrom
sebastiondev:fix/cwe22-terminology-upload-8809

Conversation

@sebastiondev

Copy link
Copy Markdown
Contributor

Summary

The terminology Excel upload endpoint (POST /terminology/uploadExcel, handler upload_excel in backend/apps/terminology/api/terminology.py) writes an uploaded file to disk using the client-supplied file.filename without 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 intended EXCEL_PATH directory — a path traversal / arbitrary file write (CWE-22).

Vulnerable code (before)

base_filename = f"{file.filename.split('.')[0]}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}"
filename = f"{base_filename}.{file.filename.split('.')[1]}"
save_path = os.path.join(path, filename)
with open(save_path, "wb") as f:
    f.write(await file.read())

file.filename is fully controlled by the client. split('.')[0] does not remove path separators, so a filename such as ../../etc/cron.d/pwn.xlsx produces a save_path like <EXCEL_PATH>/../../etc/cron.d/pwn_<hash>.xlsx, which os.path.join happily resolves outside EXCEL_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:

  1. os.path.basename(file.filename) strips any directory components from the client string before it is used to build the on-disk name.
  2. After joining, os.path.realpath is compared against os.path.realpath(path) via os.path.commonpath. If the resolved destination escapes the base directory (e.g. via a symlink under path, or any other bypass we missed), the request is rejected with HTTP 400.

The extension is now derived with os.path.splitext instead of split('.')[1], which also fixes a latent IndexError on 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:

# Craft an xlsx with a traversal filename
printf 'PWNED' > /tmp/payload.xlsx
curl -X POST "https://<host>/terminology/uploadExcel?ws_id=1" \
  -H "Authorization: Bearer <ws_admin_token>" \
  -F 'file=@/tmp/payload.xlsx;filename=../../../../tmp/sqlbot_pwn.xlsx'

Before the patch: a file matching /tmp/sqlbot_pwn_*.xlsx appears 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.xlsx and the containment check rejects anything that still resolves outside EXCEL_PATH.

Security analysis

  • Entry point: @router.post("/uploadExcel") bound to upload_excel.
  • Sink: open(save_path, "wb").write(await file.read()) and the later df.to_excel(save_error_path, ...).
  • Source: file.filename from the UploadFile — attacker-controlled.
  • Auth: the endpoint requires an authenticated workspace admin. Because SQLBot is a multi-tenant product (workspaces, roles), workspace-admin is a lower privilege than host filesystem access, so this is a meaningful privilege escalation, not an equivalence.

Adversarial review

Before submitting we tried to disprove this. Candidates considered:

  • "Starlette or FastAPI already sanitizes UploadFile.filename." It does not — filename is passed through from the multipart Content-Disposition header verbatim; this is documented and easy to reproduce locally.
  • "os.path.join will refuse traversal." It won't — os.path.join('/a/b', '../../etc/x') returns /a/b/../../etc/x and open() follows it.
  • "ws_admin already has host access." It doesn't in the shipped Docker image; ws_admin is a tenant role, not a system role.
  • "The 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

  • Re-read the handler and confirmed both write sites (save_path and save_error_path) are covered by the basename + realpath/commonpath check.
  • Verified locally that os.path.basename('../../etc/cron.d/x.xlsx') returns x.xlsx, and that os.path.commonpath yields a mismatching prefix for escapes, which the code treats as invalid.
  • Confirmed os.path.splitext preserves the extension for normal cases (foo.xlsx.xlsx) and no longer raises on filenames without a dot.
  • Existing behavior for legitimate uploads is unchanged: the random hex suffix and directory layout are preserved.

Discovered by the Sebastion AI GitHub App.

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