//nefariousplan

CVE-2026-0545: MLflow's basic-auth Only Gated Flask. Three FastAPI Routers Were Open.

pattern

cve

proof of concept

CVE-2026-0545 names an authentication bypass on POST /ajax-api/3.0/jobs/ in MLflow when basic-auth is enabled. The route is registered on a FastAPI sub-app. The basic-auth gate is a Flask before_request hook on the Flask app that FastAPI mounts at /. The two never met for five months.

The server is FastAPI now. Flask is mounted at the root.

MLflow's tracking server is in the middle of a Flask-to-FastAPI migration. The migration shipped as mlflow/server/fastapi_app.py, a thin wrapper that builds a FastAPI app, registers a handful of native FastAPI routers, and then mounts the entire Flask application at / via WSGI:

# mlflow/server/fastapi_app.py
# Include OpenTelemetry API router BEFORE mounting Flask app
# This ensures FastAPI routes take precedence over the catch-all Flask mount
fastapi_app.include_router(otel_router)

fastapi_app.include_router(job_api_router)

# Include Gateway API router for database-backed endpoints
fastapi_app.include_router(gateway_router)

# Include Assistant API router for AI-powered trace analysis
fastapi_app.include_router(assistant_router)

# Mount the entire Flask application at the root path
# This ensures compatibility with existing APIs
# NOTE: This must come AFTER include_router to avoid Flask catching all requests
fastapi_app.mount("/", _EfficientWSGIMiddleware(flask_app))

The comments are testimony. The author knew that any request whose path matched one of the four routers would be served by FastAPI and would never reach Flask. They named the four routers two lines above the mount. They named the reason for the order. They knew exactly which paths went where.

The four routers carry these prefixes:

  • otel_router: /v1/traces
  • job_api_router: /ajax-api/3.0/jobs
  • gateway_router: /gateway/
  • assistant_router: /ajax-api/3.0/mlflow/assistant

Each one is a public HTTP surface. Each one runs handlers in mlflow/server/job_api.py, mlflow/server/gateway_api.py, and so on. None of them go through mlflow/server/handlers.py (the Flask handlers), and therefore none of them go through the Flask before_request hook that basic-auth uses.

Two gates. Opposite defaults.

MLflow ships basic-auth as a separate app factory in mlflow/server/auth/__init__.py. The factory has two responsibilities. First, register a Flask before_request hook so every request that reaches Flask is authenticated and authorized. Second, when running under uvicorn, attach a FastAPI middleware so requests served directly by FastAPI are also authenticated. The factory wires both:

# mlflow/server/auth/__init__.py, end of create_app
app.before_request(_before_request)
app.after_request(_after_request)

if _MLFLOW_SGI_NAME.get() == "uvicorn":
    fastapi_app = create_fastapi_app(app)
    add_fastapi_permission_middleware(fastapi_app)
    return fastapi_app
else:
    return app

Both gates exist. Both are wired up in the same factory. They do not behave the same way.

The Flask gate denies by default:

# mlflow/server/auth/__init__.py
_UNPROTECTED_PATH_PREFIXES = ("/static", "/favicon.ico", "/health")

def is_unprotected_route(path: str) -> bool:
    prefixed = tuple(_add_static_prefix(p) for p in _UNPROTECTED_PATH_PREFIXES)
    return path.startswith(_UNPROTECTED_PATH_PREFIXES) or path.startswith(prefixed)

def _before_request():
    if is_unprotected_route(request.path):
        return
    # ...authenticate the request, run the validator, or 401

Three prefixes are exempt. Everything else requires basic-auth. A new Flask route added tomorrow inherits this policy by virtue of being on the Flask app.

The FastAPI gate allows by default. Pre-patch, this is the function the FastAPI middleware called to decide what to do with each path:

def _find_fastapi_validator(path):
    if path.startswith("/gateway/"):
        return _get_gateway_validator(path)
    return None

One prefix is gated. Everything else falls off the end of the function and returns None. Inside the middleware:

@app.middleware("http")
async def fastapi_permission_middleware(request, call_next):
    path = request.url.path
    if is_unprotected_route(path):
        return await call_next(request)
    validator = _find_fastapi_validator(path)
    if validator is None:
        return await call_next(request)
    # ...authenticate, run validator

validator is None means the middleware forwards the request without authentication. The Flask gate's missing-entry default is "deny." The FastAPI gate's missing-entry default is "forward." Three of the four routers in fastapi_app.py were not on the FastAPI gate's allowlist. Three of the four were public.

What POST /ajax-api/3.0/jobs/ does on the other side

The handler is in mlflow/server/job_api.py. It is a thin FastAPI router with no auth dependencies of its own:

job_api_router = APIRouter(prefix="/ajax-api/3.0/jobs", tags=["Job"])

class SubmitJobPayload(BaseModel):
    job_name: str
    params: dict[str, Any]
    timeout: float | None = None

@job_api_router.post("/", response_model=Job)
def submit_job(payload: SubmitJobPayload) -> Job:
    from mlflow.server.jobs import submit_job
    from mlflow.server.jobs.utils import _load_function, get_job_fn_fullname

    job_name = payload.job_name
    function_fullname = get_job_fn_fullname(job_name)
    function = _load_function(function_fullname)
    job = submit_job(function, payload.params, payload.timeout)
    return Job.from_job_entity(job)

get_job_fn_fullname looks up the requested name in _job_name_to_fn_fullname_map, which is populated at server startup from _SUPPORTED_JOB_FUNCTION_LIST in mlflow/server/jobs/__init__.py. That is the allowlist the CVE record refers to: an attacker cannot ask MLflow to call os.system directly, because os.system is not in the map.

What the attacker can do is submit any registered job, with any params dict, with any timeout, on someone else's MLflow tracking server. Whether that ends in remote code execution or "only" in invoking judge-LLM jobs against the operator's billing account depends on which functions the operator registered. The operator opted into the job runner with MLFLOW_SERVER_ENABLE_JOB_EXECUTION. They were also told they could enable basic-auth to gate it. They enabled both. The basic-auth check did not run.

The runnable proof is what the public nuclei template ships:

POST /ajax-api/3.0/jobs/ HTTP/1.1
Host: <victim>
Content-Type: application/json

{"job_name":"run_task","params":{"command":"id"}}

The matcher is status_code == 200 and the body containing "job_id": and "job_name":. No Authorization header. No nonce. No session cookie. The route did not require any of those, because the route was on a FastAPI sub-app and the basic-auth provider was a Flask before_request hook.

The patch is three more allowlist entries

Commit bb62e77 (TomeHirata, 2026-02-18, "Add missing authentication for fastapi routes") closed the bug. The diff is small:

 def _find_fastapi_validator(path):
     if path.startswith("/gateway/"):
         return _get_gateway_validator(path)
+
+    if path.startswith("/v1/traces"):
+        return _get_otel_validator(path)
+
+    if path.startswith("/ajax-api/3.0/jobs"):
+        return _get_require_authentication_validator()
+
+    if path.startswith("/ajax-api/3.0/mlflow/assistant"):
+        return _get_require_authentication_validator()
+
     return None

Three new prefixes. One is _get_otel_validator, which checks the X-Mlflow-Experiment-Id header against the user's permissions. The other two are _get_require_authentication_validator, defined in the same patch:

def _get_require_authentication_validator():
    async def validator(username: str, request: StarletteRequest) -> bool:
        return True
    return validator

Any authenticated user passes. There is no admin-only check, no per-job-name policy, no scoping to the user's own experiments. The patch does not say "only admins can submit jobs." It says "you have to be logged in to submit any job." The route went from "no credentials required" to "any valid user account can drive the job runner."

The PR landed on Feb 18, 2026. The job_api_router was added to fastapi_app.py in PR #17945 on Sept 26, 2025. The window between the router shipping and the gate landing is twenty weeks. CVE-2026-0545 was assigned three months after the patch.

The validator-finder is an allowlist. The list of routers is also an allowlist.

fastapi_app.py is a list of routers, written by the team that owns the FastAPI migration. _find_fastapi_validator in auth/__init__.py is a list of those same routers, written by the team that owns the basic-auth provider. They are two lists in two files, owned by different reviewers, that have to stay synchronized to keep the basic-auth contract that operators paid for when they passed --app-name basic-auth.

The two lists also have opposite defaults. Adding a router to fastapi_app.py is "ship a new public HTTP surface." Forgetting to add it to _find_fastapi_validator is "ship that surface without auth." There is no compile-time check, no test fixture that fails when the lists drift, no comment in fastapi_app.py reminding the next contributor to also touch auth/__init__.py. The default for omission is "open."

This is the Parallel Implementation Gap shape. Astro shipped two /_image endpoints; only one read the allowlist. mcp-atlassian's path patch guarded every download write and missed the upload reads. The shape is the same: a security utility exists, the canonical caller imports it and enforces it, a divergent caller written separately for a different runtime never imports it. In MLflow's case the two runtimes are the WSGI side and the ASGI side, and the divergent module's authors did remember to write a middleware. They forgot to update the table the middleware reads from.

What survives the patch

Three of the four FastAPI routers in fastapi_app.py were added before this CVE. Three were public for at least five months. The fourth router, the gateway, was gated from the start because the validator-finder shipped with one entry and that entry was for /gateway/. The patch raises the gated count from one to four, which is also the count of routers in fastapi_app.py today.

The fifth router will be added some time. It will be added in fastapi_app.py, by a reviewer working on the FastAPI migration. The reviewer will not necessarily know that _find_fastapi_validator exists, that it is in a different file, that it is owned by the auth provider, or that it defaults to "no auth required" when their new prefix isn't listed. The fifth router's CVE will land in the same place this one did.

PoC: projectdiscovery/nuclei-templates http/cves/2026/CVE-2026-0545.yaml

The patch closes the three open routers. It does not close the asymmetry. Flask denies by default; FastAPI allows by default; the next router is one PR away from being public.