The completion gate in dash_uploader/httprequesthandler.py reads one form field, resumableTotalChunks, and uses it as the upper bound of a range. The handler builds a list of expected chunk paths from that range, asks all([os.path.exists(p) for p in chunk_paths]), and treats a True response as a completed upload that needs to be finalised. Send resumableTotalChunks=0. range(1, 1) is empty. all([]) is True. The completion path fires, deletes the file at the target filename, and replaces it with zero bytes.
CVE-2026-38361 names that primitive and three siblings on the same handler. MITRE's record says the bug "allows a remote attacker to execute arbitrary code." The CVSS 3.1 vector on the same record says C:N/I:N/A:H, availability only. The code says the actual primitive destroys file contents. The library that ships the bug was archived ten months ago and is still pulled, on average, twenty-eight thousand times a month.
all([]) is True, and the completion path runs the destructive branch
The vulnerable function is BaseHttpRequestHandler._post(). Stable branch (0.6.1):
def _post(self):
resumableTotalChunks = request.form.get("resumableTotalChunks", type=int)
resumableChunkNumber = request.form.get("resumableChunkNumber", default=1, type=int)
resumableFilename = request.form.get("resumableFilename", default="error", type=str)
resumableIdentifier = request.form.get("resumableIdentifier", default="error", type=str)
upload_id = request.form.get("upload_id", default="", type=str)
chunk_data = request.files["file"]
temp_root = self.get_temp_root(upload_id)
temp_dir = os.path.join(temp_root, resumableIdentifier)
if not os.path.isdir(temp_dir):
os.makedirs(temp_dir)
chunk_name = get_chunk_name(resumableFilename, resumableChunkNumber)
chunk_file = os.path.join(temp_dir, chunk_name)
...
chunk_data.save(chunk_file)
...
chunk_paths = [
os.path.join(temp_dir, get_chunk_name(resumableFilename, x))
for x in range(1, resumableTotalChunks + 1)
]
upload_complete = all([os.path.exists(p) for p in chunk_paths])
if upload_complete:
...
target_file_name = os.path.join(temp_root, resumableFilename)
if os.path.exists(target_file_name):
logger.info("File %s exists already. Overwriting..", target_file_name)
os.unlink(target_file_name)
with open(target_file_name, "ab") as target_file:
for p in chunk_paths:
...
shutil.rmtree(temp_dir)
resumableTotalChunks=0 walks the function from top to bottom without branching anywhere. range(1, 0 + 1) is range(1, 1) is empty. The list comprehension produces []. The Python documentation for all says "Return True if all elements of the iterable are true (or if the iterable is empty)." That parenthetical is so foundational to the language that MITRE's reference list for CVE-2026-38361 cites the docs page for all as one of the URLs that describes the bug.
The if upload_complete branch fires. The while any([ ... lock files ... ]) loop checks any([]) and exits in zero iterations. os.path.exists(target_file_name) returns True for any file the attacker has already uploaded with that filename. os.unlink removes it. open(..., "ab") re-creates it as a zero-byte file. The for p in chunk_paths loop iterates over an empty list. shutil.rmtree removes the temporary folder.
The chunk the attacker uploaded along the way is <temp_root>/<resumableIdentifier>/<resumableFilename>_part_<chunk_number>, and is gone after the rmtree. The only persistent artifact is the target file at <temp_root>/<resumableFilename>, now empty. Whatever was at that path before the request is gone.
A request that exercises the primitive:
curl -X POST http://target/API/dash-uploader \
-F "resumableTotalChunks=0" \
-F "resumableChunkNumber=1" \
-F "resumableFilename=already-uploaded.bin" \
-F "resumableIdentifier=ignored" \
-F "upload_id=<uuid>" \
-F "file=@/dev/null"
The handler returns resumableFilename and a 200. The file at <upload_folder>/<uuid>/already-uploaded.bin is now zero bytes.
The other three primitives the same handler ships
resumableTotalChunks lives on the same line as three other resource-consumption sinks the CVE record covers.
Unbounded memory. The list comprehension at chunk_paths = [...] allocates one os.path.join result per integer in range(1, resumableTotalChunks + 1). resumableTotalChunks=30000000 allocates approximately 2.9 GB of strings per request. The advisory reproduces five concurrent requests driving a 7.7 GB host into the Linux OOM killer in two seconds; the kernel terminates the Python process and the Dash application becomes unavailable until manual restart.
Disk exhaustion. Every POST creates <temp_root>/<resumableIdentifier>/ via os.makedirs(parents=True) and writes the chunk inside. The only cleanup call in the function is shutil.rmtree(temp_dir) inside the if upload_complete branch. Sessions that never reach completion leave their chunk files in place forever. The advisory grepped the codebase for cleanup, ttl, expire, garbage, purge, cron, schedule, and periodic and got zero hits. No background process in the library reaps incomplete uploads. The defender who installs dash-uploader and forgets watches df -h for the rest of the application's life.
max_file_size enforced where the attacker did not run. dash_uploader/upload.py exposes a max_file_size argument to the Upload component, defaulted to 1024 (1 GB), and forwards it into the React component as maxFileSize:
def Upload(
...
max_file_size=1024,
...
):
...
arguments = dict(
...
maxFileSize=max_file_size * 1024 * 1024,
...
)
return Upload_ReactComponent(**arguments)
The React component checks every file's .size property against this.props.maxFileSize in removeTooLargeFilesFromQueue (src/lib/components/Upload_ReactComponent.react.js, line 311) and refuses to send anything over the limit. The server-side handler never receives the limit. There is no max_file_size keyword on configure_upload(), no constant in dash_uploader.settings, and no read of the value in httprequesthandler.py. A direct POST to /API/dash-uploader carries any number of bytes the operator's reverse proxy will accept. The control is real for the browser the attacker is not using.
The library author was aware that one client-side check was a hint. The filetypes docstring on Upload() says: "Note that this just checks the extension of the filename, and user might still upload any kind of file (by renaming)!" The max_file_size docstring on the same function says: "The maximum file size in Megabytes. Optional. Default: 1024 (1Gb)." No parenthetical. No note. The author thought of the case for one and not the other.
Three records of one bug
The NVD entry for CVE-2026-38361 reads:
An issue in fohrloop dash-uploader v.0.1.0 through v.0.7.0a2 allows a remote attacker to execute arbitrary code via the dash_uploader/httprequesthandler.py, dash_uploader/upload.py in the Upload function and max_file_size parameter, dash_uploader/configure_upload.py components
The CVSS 3.1 vector on the same record is AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H. C:N says no confidentiality impact. I:N says no integrity impact. A:H says high availability impact. The vector is the record's structured claim about what the bug does.
"Execute arbitrary code" is a confidentiality and integrity outcome. C:N/I:N cannot describe an arbitrary-code-execution bug. The text and the vector are not describing the same vulnerability.
The discoverer's advisory describes four primitives: OOM crash, file truncation to zero bytes, disk exhaustion, and bypass of the documented size limit. The OOM and the disk exhaustion belong to A:H. The file truncation belongs to I:H, not I:N. The advisory's primitives are partly availability and partly integrity. The vector picks one. The text says something neither the vector nor the advisory supports.
Three records of one bug, three descriptions of what it does, none of them identical. The defender choosing whether to prioritise this CVE against their patching budget reads whichever record loads first.
Six form fields. The hook does not name any of them.
The previous post on this library, dash-uploader Shipped a Validation Hook Marked "Important If Your Site Is Public." It Was the Patch., traced three of the form fields through _post(): upload_id, resumableIdentifier, and resumableFilename. Each flows into os.path.join or os.makedirs. Each is path-traversable. Each ends in a file written to a directory the attacker chose. CVE-2026-38360 was assigned to those reads.
CVE-2026-38361 names three more reads on the same function. resumableTotalChunks becomes the upper bound of range and the predicate of all. resumableChunkNumber becomes the suffix of the chunk filename and the index of the lock file the handler waits for. request.files["file"] becomes the chunk the handler writes to the path the other three fields composed. All six reads live in BaseHttpRequestHandler._post. All six are reachable without authentication. All six are below the same hook.
The hook is http_request_handler, the keyword argument to configure_upload() documented as This can be used to add validation for the HTTP requests (Important if your site is public!). If None, dash_uploader.HttpRequestHandler is used. If you provide a class, use a subclass of HttpRequestHandler. The hook is empty by default. The library does not enumerate any of the form fields the consumer needs to validate in their override of the hook. The previous post named this pattern Extension As Mitigation. CVE-2026-38361 is the second exhibit on the same hook.
A consumer who reads the parenthetical and adds authentication via post_before defends nothing in CVE-2026-38361. resumableTotalChunks is a numeric form field; an auth check does not enumerate it as a quantity to bound. max_file_size lives on configure_upload(), not on the hook; a consumer who set it to 10 reads the parameter as enforced and ships without server-side enforcement. The cleanup absence is structural; no post_before override fixes the lack of a sweep.
The 0.7.0-a2 dev branch refactored every form field to use the flow* prefix. resumableTotalChunks became flowTotalChunks. The completion gate is identical:
chunk_paths = [
os.path.join(
temporary_folder_for_file_chunks, get_chunk_name(r.filename, x)
)
for x in range(1, r.n_chunks_total + 1)
]
upload_complete = all([os.path.exists(p) for p in chunk_paths])
The CVE record names every published version (0.1.0 through 0.7.0a2) as affected. Both field-name conventions are vulnerable. A post_before that hardcoded resumableTotalChunks for sanitisation under 0.6.x silently does nothing under 0.7.x; the field that becomes the completion predicate in 0.7.x is named flowTotalChunks. The refactor commit (April 2023, fe68b0d) names the variable rename. It does not name the consumers who built sanitisation against the previous names.
The archive does not include the downloads
The repository was archived on 2025-07-19 by its author, Niko Föhr, with a commit titled archive message that removed the "Looking for maintainer(s)" section from the README and added one line pointing at issue #153. There is no maintainer. There is no fix planned. The CVE record lists the fixed version as none.
PyPI continues to serve the package. pepy.tech reports 28,000 downloads a month and 733,000 total. The CVE was assigned May 7, 2026, ten months after the archive notice. The fix that would have closed CVE-2026-38361 is not on a branch anywhere; the dev branch never reached 0.7.0 final, the stable branch sits at 0.6.1, and the archive notice is the last commit.
The previous post named the part of the surface that yields code execution. This post names the part that yields data destruction and availability loss. The hook the library named to defend either is the same hook, and it is still empty.
PoC: a1ohadance/CVE-2026-38361
The previous post named the part of the surface that yields code execution. This post names the part that yields data destruction and availability loss. The hook the library named to defend either is the same hook, and it is still empty.