Arbitrary file write via unsanitized upload filename (RCE) #55
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: CRITICAL
Found by the security audit of 2026-07-28, independently confirmed three ways.
The bug
backend/app/api/routes/ingest.py:58and:66build a destination path by joining atemp directory with the client-supplied filename, then write client-supplied bytes:
Two facts make this arbitrary file write:
UploadFile.filename— verified by reading the installedpython_multipart/multipart.pyandstarlette/formparsers.py. Nobasename, no separatorstripping. The value comes straight from the
Content-Dispositionheader.Path("/tmp/xyz") / "/etc/passwd"is/etc/passwd. Traversal (../) also works andresolves through
write_bytes._validate_uploadchecks onlycontent_type. The filename is never inspected.Attack
The route requires
require_role(reviewer, admin)— which, per the companion issue onauto-provisioning, any account the IdP accepts already satisfies. The payload needs no
knowledge of the install path:
/proc/self/cwdresolves to the uvicorn working directory. With--reload(the command inthe README) it executes immediately; otherwise on next restart. Remote code execution as
the app user.
Equally damaging variants using the same primitive:
filename="/proc/self/cwd/circa.db"— overwrite the database. Total loss of every datingdecision, evidence row, comment, and the audit log.
filename="/proc/self/cwd/storage/<collection_id>/ab/<sha>_front.jpg"— overwrite anoriginal scan. Both components of the storage key are returned to any authenticated user
by
GET /api/photos. This bypassesLocalStorage.put's no-overwrite guard entirely,because the write never goes through
LocalStorage.Why this matters
docs/circa-spec.md§6.1 states originals are never modified and §6.4 states nothing is everhard-deleted. Both are false while this exists. Backups (§6.2/§6.3) are unbuilt, so overwrite
is permanent. The originals are being kept but are degrading, so a rescan is not a clean
recovery path.
Fix
Never let the client filename become a path. Derive the temp name server-side and keep the
original string as data only:
Pass the sanitized
rawasoriginal_filenamefor display and filename parsing. Asdefence in depth, assert
dest.resolve().is_relative_to(tmp.resolve())before writing, andrun the app as a user with no write permission to its own source tree.
Done when
original_filenamestill records what the user uploaded, for parsing and displayReferences
backend/app/api/routes/ingest.py:58,66backend/app/services/storage.py(LocalStorage.putno-overwrite guard, bypassed here)docs/circa-spec.md§6.1, §6.4Fixed in
6268f9e. CI green.What changed
safe_temp_path()builds the destination from a server-generated UUID plus an allowlistedextension, so no client-supplied string reaches the filesystem path:
The containment assertion is redundant given the construction above it — it is there so a later
refactor that reintroduces client input into the path fails loudly instead of silently restoring
the vulnerability.
sanitize_filename()strips null bytes, normalises Windows separators to/before taking thebasename (so a client on either platform cannot smuggle a directory component), and falls back for
"",".", and"..". The sanitized value is still passed asoriginal_filename, because it isdisplayed to reviewers and parsed for album and sequence hints — the fix must not break #105.
Tests
backend/tests/test_upload_filename_safety.py, parameterised over the real payloads from the audit:/etc/passwd,/proc/self/cwd/app/api/routes/health.py,/proc/self/cwd/circa.db../../../../etc/cron.d/evil,../../circa.db,..\\..\\windows\\system32\\...subdir/nested/payload.jpg,/absolute/path/photo.jpgEach is checked twice: that the returned path is inside the temp directory, and that actually
writing through it leaves nothing outside —
rglobasserts the destination is the only fileproduced. Plus null bytes, extension allowlisting, collision safety between two uploads of the same
name, and that album/sequence filename hints survive sanitizing.
Not addressed here, deliberately
reviewer, though its docstring says "Admin-only in Phase 1". Tighteningthat belongs to #73 with the rest of the permission matrix.
That belongs with the deployment work in #50 and is not enforceable from application code.