//nefariousplan

CVE-2026-38360: dash-uploader Shipped a Validation Hook Marked "Important If Your Site Is Public." It Was the Patch.

patterns

cve

proof of concept

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.

The 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.

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.

The 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.

Three form fields. Three sinks. One function.

The vulnerable function is BaseHttpRequestHandler._post() in dash_uploader/httprequesthandler.py. The stable branch (0.6.1) reads:

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)

    # get the chunk data
    chunk_data = request.files["file"]

    # make our temp directory
    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)

    # save the chunk data
    chunk_name = get_chunk_name(resumableFilename, resumableChunkNumber)
    chunk_file = os.path.join(temp_dir, chunk_name)
    ...
    target_file_name = os.path.join(temp_root, resumableFilename)

Three form fields enter the filesystem in this one function. Each enters by a different route. Each ends in a different sink.

upload_id flows into get_temp_root:

def get_temp_root(self, upload_id):
    return (
        os.path.join(self.upload_folder, upload_id)
        if self.use_upload_id
        else self.upload_folder
    )

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 execs any line beginning with import. One worker recycle later, the attacker has code execution inside the Dash process.

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.

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.

Three 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.

The 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.

This is the unauth-write-to-execution-path pattern in a Python wrapper. Pix for WooCommerce wrote PHP to a directory PHP served; Nginx-UI 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.

The hook was named for this

The hook the library shipped instead of validation lives in configure_upload.py:

def configure_upload(
    app, folder, use_upload_id=True, upload_api=None, http_request_handler=None
):
    """
    ...
    http_request_handler: None or class
        Used for custom configuration on the Http POST and GET requests.
        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.
        See the documentation of dash_uploader.HttpRequestHandler for
        more details.
    """

The hook is an extension point. The consumer is expected to subclass HttpRequestHandler and override post_before:

class HttpRequestHandler(BaseHttpRequestHandler):
    def post_before(self):
        pass

    def post(self):
        self.post_before()             # consumer's validation goes here
        returnvalue = super().post()   # super().post() calls _post(),
                                        # which reads request.form
        self.post_after()
        return returnvalue

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.

The 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."

The 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.

A 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.

The 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.

The dev branch refactored everything but the sinks

In 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*.

The same three sinks survived intact. From the 0.7.0-a2 dev branch:

class RequestData:
    def __init__(self, request):
        self.filename = request.form.get("flowFilename", default="error", type=str)
        self.unique_identifier = request.form.get(
            "flowIdentifier", default="error", type=str
        )
        ...
        self.upload_id = request.form.get("upload_id", default="", type=str)


class BaseHttpRequestHandler:
    def __init__(self, server, upload_folder, use_upload_id):
        ...
        self.upload_folder = pathlib.Path(upload_folder)

    def _post(self):
        r = RequestData(request)
        upload_session_root = self.get_upload_session_root(r.upload_id)
        temporary_folder_for_file_chunks = upload_session_root / r.unique_identifier

        if not temporary_folder_for_file_chunks.exists():
            temporary_folder_for_file_chunks.mkdir(parents=True)

The variable names are better. The behaviour is the same. upload_session_root / r.unique_identifier with r.unique_identifier set to ../../foo produces <base>/../../foo, and mkdir(parents=True) follows the resolved path. pathlib.Path does not canonicalise; the / operator concatenates and lets the kernel do the rest.

There 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.

The 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.

The archive is the patch the docstring promised

On 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.

The 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.

Muhammad 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.

The 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.

post_before is where the library stopped working

The 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.

The 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.

Patterns 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.

The hook is not the mitigation. The hook is the documentation the library wrote in place of the mitigation.

PoC: a1ohadance/CVE-2026-38360

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.