-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"In April 2021, dash-uploader added a keyword argument to `configure_upload()` called `http_request_handler`. The author wrote one sentence into the docstring to explain what the argument was for: \"This can be used to add validation for the HTTP requests (Important if your site is public!).\" The same release left the three `request.form.get(...)` reads that flow into `os.path.join` and `os.makedirs` exactly where they were. Eighteen releases later, on 2026-05-07, MITRE assigned CVE-2026-38360 to those same reads.\n\nThe parenthetical is the patch. Five years of \"Important if your site is public!\" was what the library shipped in place of validation. The repository archived itself in July 2025 with no maintainer and no fix planned. PyPI continues to serve roughly 28,000 downloads of `dash-uploader` a month.\n\n## Three form fields. Three sinks. One function.\n\nThe vulnerable function is `BaseHttpRequestHandler._post()` in `dash_uploader/httprequesthandler.py`. The stable branch (0.6.1) reads:\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 # get the chunk data\n chunk_data = request.files[\"file\"]\n\n # make our temp directory\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 # save the chunk data\n chunk_name = get_chunk_name(resumableFilename, resumableChunkNumber)\n chunk_file = os.path.join(temp_dir, chunk_name)\n ...\n target_file_name = os.path.join(temp_root, resumableFilename)\n```\n\nThree form fields enter the filesystem in this one function. Each enters by a different route. Each ends in a different sink.\n\n`upload_id` flows into `get_temp_root`:\n\n```python\ndef get_temp_root(self, upload_id):\n return (\n os.path.join(self.upload_folder, upload_id)\n if self.use_upload_id\n else self.upload_folder\n )\n```\n\n`os.path.join(\"/var/uploads\", \"../../../../usr/local/lib/python3.10/site-packages\")` returns `/var/uploads/../../../../usr/local/lib/python3.10/site-packages`. The kernel resolves the parent traversals on the next syscall. `temp_root` is now Python's site-packages directory. A two-line `.pth` file dropped there fires on the next interpreter startup, because Python's `site.py` reads every `*.pth` file in every site-packages directory at import time and `exec`s any line beginning with `import`. One worker recycle later, the attacker has code execution inside the Dash process.\n\n`resumableIdentifier` flows into `os.makedirs(temp_dir)`. This is the secondary write primitive. It creates arbitrary directories anywhere the process can reach. Combined with the chunk that follows, it lets the attacker provision intermediate parents for any destination the rest of the function would otherwise stumble into.\n\n`resumableFilename` becomes `target_file_name` at the end of the chunk-merging loop. Even when `upload_id` is a UUID the way the official quickstart shows it, `resumableFilename=../../../../var/www/html/shell.php` traverses out of the upload directory at the moment of final write. Filename traversal is independent of directory traversal. Both work. They compose.\n\nThree sinks share one root cause. The function reads each path component directly from `request.form` and passes each to `os.path.join` or `os.makedirs`. There is no validation. There is no canonicalisation. There is no documentation telling the consumer there ought to be.\n\nThe Flask request route that exposes this function is registered by the library itself, on `app.server`, at `settings.upload_api` (default `/API/dash-uploader`). The route requires no authentication. The official `usage.py` quickstart calls `du.configure_upload(app, UPLOAD_FOLDER_ROOT)` with no `http_request_handler` argument and ships an app whose only check on the upload route is that the POST body parses as multipart.\n\nThis is the unauth-write-to-execution-path pattern in a Python wrapper. [Pix for WooCommerce](/posts/pix-woocommerce-nonce-is-not-auth) wrote PHP to a directory PHP served; [Nginx-UI](/posts/nginx-ui-backup-signature-key-on-the-request) wrote shell strings to a config file the next reload re-read; dash-uploader writes Python to a directory `.pth`-aware processes auto-execute on the next interpreter startup. Three runtimes, one shape: write to a path the runtime executes, no credentials required.\n\n## The hook was named for this\n\nThe hook the library shipped instead of validation lives in `configure_upload.py`:\n\n```python\ndef configure_upload(\n app, folder, use_upload_id=True, upload_api=None, http_request_handler=None\n):\n \"\"\"\n ...\n http_request_handler: None or class\n Used for custom configuration on the Http POST and GET requests.\n This can be used to add validation for the HTTP requests (Important\n if your site is public!). If None, dash_uploader.HttpRequestHandler is used.\n If you provide a class, use a subclass of HttpRequestHandler.\n See the documentation of dash_uploader.HttpRequestHandler for\n more details.\n \"\"\"\n```\n\nThe hook is an extension point. The consumer is expected to subclass `HttpRequestHandler` and override `post_before`:\n\n```python\nclass HttpRequestHandler(BaseHttpRequestHandler):\n def post_before(self):\n pass\n\n def post(self):\n self.post_before() # consumer's validation goes here\n returnvalue = super().post() # super().post() calls _post(),\n # which reads request.form\n self.post_after()\n return returnvalue\n```\n\n`post_before()` runs before `_post()` reads the form. Flask has already parsed the request body by the time `post_before` fires, so the hook can call `request.form.get(...)` and call `flask.abort()` if it wants. The hook is not blind to the request. The hook is empty.\n\nThe library does not tell the consumer which form fields it will read. The library does not tell the consumer which form fields it will write to the filesystem. The library does not tell the consumer the three form fields are path components. A developer who reads \"validation for the HTTP requests (Important if your site is public!)\" has no enumeration to validate against. They will validate what they think the library is doing, which is \"accept a chunked file upload,\" not \"concatenate three form fields into a filesystem path and call `makedirs`.\"\n\nThe most likely use of `post_before` is authentication. The parenthetical's force, \"Important if your site is public!\", nudges the consumer toward adding a check that the caller is logged in. A consumer who does exactly that ships a library with the three path-component sinks intact. Authentication does not prevent path traversal. The path-traversal vulnerability survives every auth gate the consumer can bolt on top of the hook, because the library reads the form data the same way regardless of whether the request arrived authenticated.\n\nA consumer who reads `_post` carefully and writes a `post_before` that sanitises `upload_id`, `resumableFilename`, and `resumableIdentifier` against an allowlist closes the gap. There is no comment in the library saying these three names matter. There is no test in the library exercising any of them with a `..`. The consumer who closes the gap closes it by reading the library as a reference for what to rewrite. At which point they are no longer using the library, they are auditing it.\n\nThe library names the threat. The library names the seam. The library does not name the inputs. The hook is positioned as the place validation belongs. The hook is the place the library declined to ship validation.\n\n## The dev branch refactored everything but the sinks\n\nIn April 2023, the maintainer landed commit `fe68b0d` on the dev branch: \"refactor BaseHttpRequestHandler (use pathlib, better names for variables,..)\". The refactor touched every line of `_post()`. It introduced a `RequestData` class that encapsulates the form reads. It switched from `os.path` to `pathlib.Path`. It renamed the request fields from `resumable*` to `flow*`.\n\nThe same three sinks survived intact. From the 0.7.0-a2 dev branch:\n\n```python\nclass RequestData:\n def __init__(self, request):\n self.filename = request.form.get(\"flowFilename\", default=\"error\", type=str)\n self.unique_identifier = request.form.get(\n \"flowIdentifier\", default=\"error\", type=str\n )\n ...\n self.upload_id = request.form.get(\"upload_id\", default=\"\", type=str)\n\n\nclass BaseHttpRequestHandler:\n def __init__(self, server, upload_folder, use_upload_id):\n ...\n self.upload_folder = pathlib.Path(upload_folder)\n\n def _post(self):\n r = RequestData(request)\n upload_session_root = self.get_upload_session_root(r.upload_id)\n temporary_folder_for_file_chunks = upload_session_root / r.unique_identifier\n\n if not temporary_folder_for_file_chunks.exists():\n temporary_folder_for_file_chunks.mkdir(parents=True)\n```\n\nThe variable names are better. The behaviour is the same. `upload_session_root / r.unique_identifier` with `r.unique_identifier` set to `../../foo` produces `/../../foo`, and `mkdir(parents=True)` follows the resolved path. `pathlib.Path` does not canonicalise; the `/` operator concatenates and lets the kernel do the rest.\n\nThere is a second consequence the refactor produced, and it has nothing to do with the sinks themselves. Field names changed. A `post_before` override that hardcoded `request.form.get(\"resumableFilename\")` for sanitisation works under 0.6.x and silently does nothing under 0.7.x, because the field that becomes the filename in 0.7.x is named `flowFilename`. The hook the library marks for validation depends on the consumer keeping their validation logic in sync with library refactors that did not change the threat. The refactor commit message names the user-facing rename. It does not name the consumers downstream who built their security against the old name and would now ship the upgrade with no warning that the inputs they were watching had been replaced.\n\nThe dependency for the safer alternative was already in the tree. dash-uploader requires `dash`, which requires `flask`, which requires `werkzeug`. `werkzeug.utils.secure_filename` exists in werkzeug for exactly this case. The function strips path separators and traversals from a filename and returns a safe basename. The library could have called it on `r.filename` and on `r.unique_identifier`, and could have rejected `upload_id` values containing `..` or starting with `/`. Three lines. The library never imported it.\n\n## The archive is the patch the docstring promised\n\nOn July 19, 2025, the repository was archived. Issue #153, titled \"Looking for new maintainers,\" contains the final message from the author and a request for someone to take over. No one did. The dev branch never reached 0.7.0 final. The stable branch sits at 0.6.1.\n\nThe package is still on PyPI. The package is still pulled, on average, 28,000 times a month, sustained daily volume through the archive notice and onward. Eleven dependent packages name `dash-uploader` as a requirement. Six dependent repositories pin it. None of those consumers will be migrated by the upstream, because there is no upstream.\n\nMuhammad Fitri Bin Mohd Sultan reported the vulnerability to MITRE in March 2026, eight months after the archive notice. CVE-2026-38360 was assigned on May 7, 2026. The CVE record names every published version (`0.1.0` through `0.7.0a2`) as affected and lists the fixed version as \"none (project archived 2025-07-19).\" Two hundred and ninety-two days between archive and CVE; no patch in either direction.\n\nThe phrase the library used in place of validation in 2021 is the phrase the library uses in place of validation in 2026. \"Important if your site is public!\" has been the receipt for the design decision since the day it was committed.\n\n## post_before is where the library stopped working\n\nThe dash-uploader case is what extension-as-mitigation looks like in its final form. A library names a threat in a docstring. The library positions an extension point as the seam where the consumer should defend against the threat. The library does not enumerate the inputs the consumer needs to defend against. The library keeps the sinks. The library archives.\n\nThe consumer who follows the docstring's guidance never closes the gap, because the docstring's guidance does not include the gap's address. The consumer who reads `_post` and writes their own subclass that overrides every form read closes the gap by re-implementing the library. The consumer who deploys behind authentication, exactly as the docstring's parenthetical implies, ships a vulnerability that lives below their auth layer and is reachable from every authenticated user.\n\nPatterns like this also break sibling defences. The seam the library promises is a place the consumer can audit. The sinks the library keeps are not. A WAF rule that rejects `..` in `upload_id` against version 0.6.x stops working when 0.7.x renames `resumableIdentifier` to `flowIdentifier`. A reverse-proxy template that allowlists UUIDs for `resumableFilename` is irrelevant when the next library version reads `flowFilename`. The consumer who built a defence against last version's sink discovers, on package update, that the field they were watching no longer exists, and that nothing in the release notes flagged the change as security-relevant.\n\nThe hook is not the mitigation. The hook is the documentation the library wrote in place of the mitigation.\n\nPoC: [a1ohadance/CVE-2026-38360](https://github.com/a1ohadance/CVE-2026-38360)","closing_line":"`Important if your site is public!` is in the source. It has been in the source for five years. It is what the library shipped instead of validation.","hook_md":"In April 2021, dash-uploader added a keyword argument to `configure_upload()` called `http_request_handler`. The author wrote one sentence into the docstring to explain what the argument was for: \"This can be used to add validation for the HTTP requests (Important if your site is public!).\" The same release left the three `request.form.get(...)` reads that flow into `os.path.join` and `os.makedirs` exactly where they were. Eighteen releases later, on 2026-05-07, MITRE assigned CVE-2026-38360 to those same reads.\n\nThe parenthetical is the patch. Five years of \"Important if your site is public!\" was what the library shipped in place of validation. The repository archived itself in July 2025 with no maintainer and no fix planned. PyPI continues to serve roughly 28,000 downloads of `dash-uploader` a month.","post_id":283,"slug":"dash-uploader-important-if-your-site-is-public","title":"CVE-2026-38360: dash-uploader Shipped a Validation Hook Marked \"Important If Your Site Is Public.\" It Was the Patch.","type":"initial","unreadable_sentence":"'Important if your site is public!' is what the library shipped instead of validation."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatjw8gAKCRDeZjl4jgkQ Joe+AP0b3xz4irVoowtIiPn4hEEnkcFmiabyRcHRsJ8lVC48mwD/ZCckNLG4nHbV GilbUvK/eph4UrRhf9ykYO1g2uENVgo= =GTKG -----END PGP SIGNATURE-----