POST /upload uses the request-supplied resumableFilename directly as a key into the shared temporary directory, without the character filter it applies to resumableIdentifier and without namespacing it per user. A valid user holding the upload permission can read any file in that directory into their own home directory, append to it, and delete it.
This reproduces the cross-user batch-archive disclosure of GHSA-f74m-x83r-c4v4 through a different route — it still works with the fix for that advisory (PR #597) applied — and additionally allows deleting the login rate-limit lockfile.
Reported with explicit preconditions and bounded impact, as before: the primitive is confined to the temporary directory, not the whole filesystem.
backend/Controllers/UploadController.php:61 — $file_name = $request->input('resumableFilename', 'file') is not passed through the preg_replace('/[^0-9a-zA-Z_]/', '', ...) that line 66 applies to resumableIdentifier.$prefix namespaced by the sanitized username, but $file_name reaches the temporary storage at lines 118, 121 and 125 without that prefix.if ($chunks_size >= $total_size) is entered whenever resumableTotalSize is 0.for ($i = 1; $i <= $total_chunks; ++$i) does not execute when resumableTotalChunks is 0, so nothing is written to $file_name.readStream($file_name) therefore reads the pre-existing temporary file of that exact name.remove($file_name) deletes it.Path traversal outside the temporary directory is prevented by Tmpfs::sanitizeFilename(), so the primitive is bounded to that directory — but every user's transient objects share it.
Preconditions: a multi-user deployment with distinct per-user homedirs. The attacker is a valid low-privileged user holding upload (a first-class permission in the admin UI, and present in the shipped users.json.blank admin set).
POST /batchdownload; the JSON response returns the archive id.POST /upload with resumableFilename=<victim's id>, resumableTotalChunks=0, resumableTotalSize=0, resumableRelativePath=/, and any small valid file part.{"data":"Stored"}. The victim's archive is now byte-identical in the attacker's home directory, retrievable through the ordinary GET /download. The victim's temporary archive has been deleted.Verified end to end with a PHPUnit test against current master; the archive contents matched byte for byte. Test file available on request.
Missing authorization (CWE-862) on temporary storage objects.
ZipArchiver::addFileFromStorage() writes while an archive is being built (those still use uniqid(), which is timestamp-derived).AuthController::login() stores the login rate-limit counter as md5($ip).'.lock' in that same directory (backend/Controllers/AuthController.php:36, checked at line 44, written at line 58). An attacker can compute their own lockfile name and delete it, defeating the lockout.resumableTotalChunks >= 1, line 118 write($file_name, ..., true) appends attacker-controlled bytes to an arbitrary existing temporary file.Do not use the request-supplied filename as a storage key. Namespace the assembly file per user — for example reuse the existing $prefix — and carry the display filename separately for the storage->store() call.
Note that simply applying the [^0-9a-zA-Z_] filter to resumableFilename would corrupt legitimate uploads (sample.txt becomes sampletxt), because $final['filename'] is what becomes the stored name. The fix needs the namespacing, not just the filter.
{
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-09T22:20:08Z",
"nvd_published_at": "2026-11-06T22:18:00Z",
"severity": "MODERATE"
}