-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"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.\n\nCVE-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.\n\n## `all([])` is `True`, and the completion path runs the destructive branch\n\nThe vulnerable function is `BaseHttpRequestHandler._post()`. Stable branch (0.6.1):\n\n```python\ndef _post(self):\n resumableTotalChunks = request.form.get(\"resumableTotalChunks\", type=int)\n resumableChunkNumber = request.form.get(\"resumableChunkNumber\", default=1, type=int)\n resumableFilename = request.form.get(\"resumableFilename\", default=\"error\", type=str)\n resumableIdentifier = request.form.get(\"resumableIdentifier\", default=\"error\", type=str)\n upload_id = request.form.get(\"upload_id\", default=\"\", type=str)\n\n chunk_data = request.files[\"file\"]\n\n temp_root = self.get_temp_root(upload_id)\n temp_dir = os.path.join(temp_root, resumableIdentifier)\n if not os.path.isdir(temp_dir):\n os.makedirs(temp_dir)\n\n chunk_name = get_chunk_name(resumableFilename, resumableChunkNumber)\n chunk_file = os.path.join(temp_dir, chunk_name)\n ...\n chunk_data.save(chunk_file)\n ...\n\n chunk_paths = [\n os.path.join(temp_dir, get_chunk_name(resumableFilename, x))\n for x in range(1, resumableTotalChunks + 1)\n ]\n upload_complete = all([os.path.exists(p) for p in chunk_paths])\n\n if upload_complete:\n ...\n target_file_name = os.path.join(temp_root, resumableFilename)\n if os.path.exists(target_file_name):\n logger.info(\"File %s exists already. Overwriting..\", target_file_name)\n os.unlink(target_file_name)\n\n with open(target_file_name, \"ab\") as target_file:\n for p in chunk_paths:\n ...\n shutil.rmtree(temp_dir)\n```\n\n`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.\n\nThe `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.\n\nThe chunk the attacker uploaded along the way is `//_part_`, and is gone after the `rmtree`. The only persistent artifact is the target file at `/`, now empty. Whatever was at that path before the request is gone.\n\nA request that exercises the primitive:\n\n```bash\ncurl -X POST http://target/API/dash-uploader \\\n -F \"resumableTotalChunks=0\" \\\n -F \"resumableChunkNumber=1\" \\\n -F \"resumableFilename=already-uploaded.bin\" \\\n -F \"resumableIdentifier=ignored\" \\\n -F \"upload_id=\" \\\n -F \"file=@/dev/null\"\n```\n\nThe handler returns `resumableFilename` and a 200. The file at `//already-uploaded.bin` is now zero bytes.\n\n## The other three primitives the same handler ships\n\n`resumableTotalChunks` lives on the same line as three other resource-consumption sinks the CVE record covers.\n\n**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.\n\n**Disk exhaustion.** Every POST creates `//` 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.\n\n**`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`:\n\n```python\ndef Upload(\n ...\n max_file_size=1024,\n ...\n):\n ...\n arguments = dict(\n ...\n maxFileSize=max_file_size * 1024 * 1024,\n ...\n )\n return Upload_ReactComponent(**arguments)\n```\n\nThe 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.\n\nThe 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.\n\n## Three records of one bug\n\nThe NVD entry for CVE-2026-38361 reads:\n\n> 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\n\nThe 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.\n\n\"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.\n\nThe 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.\n\nThree 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.\n\n## Six form fields. The hook does not name any of them.\n\nThe previous post on this library, [dash-uploader Shipped a Validation Hook Marked \"Important If Your Site Is Public.\" It Was the Patch.](/posts/dash-uploader-important-if-your-site-is-public), 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.\n\nCVE-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.\n\nThe 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.\n\nA 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.\n\nThe 0.7.0-a2 dev branch refactored every form field to use the `flow*` prefix. `resumableTotalChunks` became `flowTotalChunks`. The completion gate is identical:\n\n```python\nchunk_paths = [\n os.path.join(\n temporary_folder_for_file_chunks, get_chunk_name(r.filename, x)\n )\n for x in range(1, r.n_chunks_total + 1)\n]\nupload_complete = all([os.path.exists(p) for p in chunk_paths])\n```\n\nThe 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.\n\n## The archive does not include the downloads\n\nThe 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](https://github.com/fohrloop/dash-uploader/issues/153). There is no maintainer. There is no fix planned. The CVE record lists the fixed version as `none`.\n\nPyPI 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.\n\nThe 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.\n\nPoC: [a1ohadance/CVE-2026-38361](https://github.com/a1ohadance/CVE-2026-38361)","closing_line":"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.","hook_md":"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.\n\nCVE-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.","post_id":284,"slug":"dash-uploader-all-of-nothing-is-completion","title":"CVE-2026-38361: dash-uploader Treats Zero Chunks As a Completed Upload. Completion Deletes the Target.","type":"initial","unreadable_sentence":"`all([])` is `True`. The library reads it as completion. Completion deletes the file at the target path."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatpLqAAKCRDeZjl4jgkQ JjCoAQDOLKuw433ne9XspbiXLMUhSNUz2N/b1lvDyg1HAtXBvwD/XZvIDiiUVVGL jfcd7zZxK0OFTh2UQNLX6zQwLWnMfQM= =2U7G -----END PGP SIGNATURE-----