//nefariousplan

CVE-2026-7711: MindsDB Built ModelWrapperSafe and ModelWrapperUnsafe. The HTTP Form Picks.

pattern

cve

MindsDB ships two ways to run user-supplied BYOM code. One is named ModelWrapperSafe and runs the upload in a subprocess inside a virtualenv. The other is named ModelWrapperUnsafe and runs it in the MindsDB worker process. CVE-2026-7711 is PUT /api/handlers/byom/<name>, an HTTP route where the form's type field picks which wrapper runs the Python the caller just uploaded. The route lives in MindsDB's protected_namespaces list. The flag that activates the list, config["auth"]["http_auth_enabled"], defaults to false.

The function on the exec call is named import_string

In mindsdb/integrations/handlers/byom_handler/proc_wrapper.py, the function that turns the uploaded bytes into a running Python module is six lines:

def import_string(code, module_name='model'):
    # import string as python module
    import types
    module = types.ModuleType(module_name)
    exec(code, module.__dict__)
    return module

The name says import. The body says exec. The execution context is the fresh module's __dict__, which means every top-level statement in code runs as part of the call: imports, top-level subprocess.run, top-level file writes, top-level network calls. The function returns the module object only after every top-level statement has completed.

The PoC's payload is exactly the shape this guarantees:

import subprocess
result = subprocess.run(['whoami'], capture_output=True, text=True)
open('/tmp/pwned.txt', 'w').write('PWNED: ' + result.stdout)

class MyModel:
    def train(self, df, target, args=None): return self
    def predict(self, df, args=None): return df

The subprocess.run runs during exec. The class definition is decoration. The class is there to satisfy a check that has not run yet.

The check happens after the exec

In the same file:

def check_module(module, mode):
    # checks module and returns info
    methods = {}
    if mode == 'custom_function':
        methods = get_methods_info(module)
    else:
        # is BYOM, check it.
        model_class = find_model_class(module)
        if model_class is None:
            raise RuntimeError('Unable to find model class (it has to have `train` and `predict` methods)')
        model_class()
    return {'methods': methods}

The wrapper that calls it is ModelWrapperUnsafe, in byom_handler.py:

class ModelWrapperUnsafe:
    """Model wrapper that executes learn/predict in current process"""

    def __init__(self, code, modules_str, engine_id, engine_version: int):
        self.module = import_string(code)
        model_instance = None
        model_class = find_model_class(self.module)
        if model_class is not None:
            model_instance = model_class()
        self.model_instance = model_instance

import_string is the first statement. By the time find_model_class walks the module's namespace looking for a class with train and predict methods, the attacker's subprocess.run(['whoami']) has already run. The gate's only failure mode is RuntimeError('Unable to find model class'), raised from inside a process that has already executed the attacker's code. Adding train and predict methods to the class is the price of an exception not getting raised. The price is two lines.

BYOM_TYPE has two values. The form picks one.

At the top of byom_handler.py:

BYOM_TYPE = Enum("BYOM_TYPE", ["INHOUSE", "VENV"])

INHOUSE selects ModelWrapperUnsafe. VENV selects ModelWrapperSafe. The two wrappers exist side by side. One runs the upload in the MindsDB worker process. The other runs it in a subprocess inside a virtualenv. Both are real implementations; both ship in every release.

The selection lives in the HTTP handler. From mindsdb/api/http/namespaces/handlers.py:

@ns_conf.route("/byom/<name>")
@ns_conf.param("name", "Name of the model")
class BYOMUpload(Resource):
    @ns_conf.doc("put_file")
    @api_endpoint_metrics("PUT", "/handlers/byom/handler")
    def put(self, name):
        params = prepare_formdata()
        code_file_path = params["code"]
        try:
            module_file_path = params["modules"]
        except KeyError:
            module_file_path = Path(code_file_path).parent / "requirements.txt"
            module_file_path.touch()
            module_file_path = str(module_file_path)
        connection_args = {
            "code": code_file_path,
            "modules": module_file_path,
            "mode": params.get("mode"),
            "type": params.get("type"),
        }
        ast_query = CreateMLEngine(name=Identifier(name), handler="byom", params=connection_args)
        sql_session = SessionController()
        command_executor = ExecuteCommands(sql_session)
        command_executor.execute_command(ast_query)
        return "", 200

params.get("type") reads the form's type field. The PoC sends -F 'type=inhouse'. The string is uppercased downstream, looked up in BYOM_TYPE, used to select the wrapper. There is no allowlist step. There is no operator approval. The form is the policy.

/api/handlers is in protected_namespaces. Protection is opt-in.

The BYOM upload route is mounted under /api/handlers. The list of HTTP namespaces the auth middleware will check on every request lives in mindsdb/api/http/initialize.py:

protected_namespaces = [
    tab_ns,
    utils_ns,
    conf_ns,
    file_ns,
    sql_ns,
    analysis_ns,
    handlers_ns,
    tree_ns,
    projects_ns,
    databases_ns,
    views_ns,
    models_ns,
    chatbots_ns,
    skills_ns,
    agents_ns,
    jobs_ns,
    knowledge_bases_ns,
]

handlers_ns is on the list. A defender reading the source and grepping for /api/handlers will find it there. The list reads like enforcement.

The enforcement is one branch lower:

@app.before_request
def before_request():
    ctx.set_default()
    h = request.headers.get("Authorization")
    if not h or not h.startswith("Bearer "):
        bearer = None
    else:
        bearer = h.split(" ", 1)[1].strip() or None

    http_auth_type = config["auth"]["http_auth_type"]
    if (
        config["auth"]["http_auth_enabled"] is True
        and any(request.path.startswith(f"/api{ns.path}") for ns in protected_namespaces)
        and (
            (http_auth_type == HTTP_AUTH_TYPE.SESSION and check_session_auth() is False)
            or (http_auth_type == HTTP_AUTH_TYPE.TOKEN and verify_pat(bearer) is False)
            or (
                http_auth_type == HTTP_AUTH_TYPE.SESSION_OR_TOKEN
                and check_session_auth() is False
                and verify_pat(bearer) is False
            )
        )
    ):
        return http_error(HTTPStatus.UNAUTHORIZED, "Unauthorized", "Authorization is required to complete the request")

The outermost condition is config["auth"]["http_auth_enabled"] is True. If that flag is false, the entire boolean expression short-circuits and the request proceeds. The default value of the flag is in mindsdb/utilities/config.py:

"auth": {
    "http_auth_type": HTTP_AUTH_TYPE.SESSION_OR_TOKEN,
    "http_auth_enabled": False,
    "http_permanent_session_lifetime": datetime.timedelta(days=31),
    "username": "mindsdb",
    "password": "",
},

http_auth_enabled defaults to False. The default username is mindsdb. The default password is the empty string. A freshly cloned and pip install -e'd MindsDB, started with python -m mindsdb --api=http, listens on 0.0.0.0:47334 and accepts PUT /api/handlers/byom/<name> from anyone who can route a packet to that port.

The protection list contains the route. The flag that activates the protection list defaults off.

The cloud build disables BYOM. The self-hosted build does not.

byom_handler.py reads the deployment mode at construction time:

is_cloud = Config().get("cloud", False)
if is_cloud is True:
    byom_enabled = os.environ.get("MINDSDB_BYOM_ENABLED", "false")

The cloud build defaults MINDSDB_BYOM_ENABLED to "false". The self-hosted build does not run this branch, because Config().get("cloud", False) returns False on a self-hosted instance. The vendor's own deployment of MindsDB does not expose the BYOM upload route under this configuration. The exact same release tarball, run on a researcher's laptop or a tenant's bare-metal box, does.

The toggle that turns BYOM off in cloud is in the codebase. The vendor reached for it for their own deployment. The default configuration that ships in pip install mindsdb does not reach for it.

"Unrestricted upload" is the wrong frame

The CWE assigned to CVE-2026-7711 is CWE-434, unrestricted upload of file with dangerous type. The PoC writeup leads with "RCE via file upload." Both framings miss what is happening.

prepare_formdata rejects field names other than code and modules, rejects source filenames containing path separators, and writes to a fresh tempfile.mkdtemp(prefix="mindsdb_file_") directory. The file lands exactly where the developer intended it to land. The destination is not webroot. It is not the user upload directory of a CMS. It is a temp file the server reads back into memory four function calls later and passes to exec.

The path on disk is not the path that matters. The path that matters runs from the form field code, into a tempfile, into params["code"], into connection_args["code"], into the handler's model proxy, into ModelWrapperUnsafe(code=<file contents>, ...), into import_string(code), into exec(code, module.__dict__). Calling this a file-upload bug concedes the framing the code is in. The handler is not an upload pipeline that forgot to validate. It is an exec pipeline that is documented to call exec.

This is the architectural shape Unauth Write To Execution Path names, with a variant that has come up before in Nginx-UI's restore route: the execution path is not a webroot. It is a path the server reads back into a dangerous interpreter on its own, without a second request, without a CGI dispatch, without an include from a page handler. The pattern's three architectural decisions still compose: an unauthenticated trigger, a write into a path the server later treats as code, and a contract that does not validate what is in the path.

What the class names mean

ModelWrapperUnsafe is the class name as it appears in byom_handler.py. ModelWrapperSafe is the sibling. The developer wrote both. The developer wrote the word "Unsafe" into one of them. The developer wrote the word "Safe" into the other.

The two classes are evidence of a threat model. Whoever named them understood that running uploaded Python in the worker process was unsafe and that running it in a virtualenv subprocess was the safer alternative. They built the safer alternative. They put the choice between the two on the wire, in a form field, on a route whose authentication defaults off, in a build whose deployment-mode check disables BYOM in the cloud and does not disable it self-hosted.

The CVE description ends with "the vendor was contacted early about this disclosure but did not respond in any way." That sentence is in the NVD record. The class is still named Unsafe in the current main branch. The form still picks it.

PoC: nn0nkey/JD-Security-SHENYI-Team

The patch will close this CVE. It will not delete ModelWrapperUnsafe.