{"repro_id":"REPRO-2026-00335","version":7,"title":"MLflow unauthenticated full-read SSRF in webhook delivery via redirect-follow bypass of _validate_webhook_url guard","repro_type":"security","status":"published","severity":"critical","description":"A default unauthenticated MLflow Tracking Server exposes model-registry webhook creation and synchronous testing. A public HTTPS webhook can redirect to loopback, private, or metadata HTTP services; MLflow follows the redirect without revalidation and reflects the final status and body, yielding unauthenticated full-read SSRF.","root_cause":"## Summary\n\nCVE-2026-64849 is confirmed in the real default MLflow Tracking Server API. MLflow 3.13.0 validates only the configured webhook URL, then its Requests session follows an HTTPS `302` redirect to an unvalidated private address. An unauthenticated caller can create that webhook and invoke `POST /api/2.0/mlflow/webhooks/{id}/test`; MLflow fetches the private resource and reflects its body. The same current-run procedure against MLflow 3.15.0 reaches the public HTTPS redirector but blocks the redirected connection before sending HTTP to the private service.\n\n## Impact\n\n- **Affected component:** MLflow Tracking Server model-registry webhook API and `mlflow/webhooks/delivery.py`.\n- **Affected versions:** Upstream advisory scope is MLflow versions before 3.15.0. This run directly proves 3.13.0 vulnerable and 3.15.0 fixed.\n- **Risk level and consequences:** Critical, unauthenticated full-read SSRF. A remote caller can read HTTP resources reachable from the MLflow host, including private network and cloud metadata-style services, and receive those bytes in the API response.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unauthenticated full-read SSRF through the default remote API, with internal response data reflected to the caller.\n- **Reproduced impact from this run:** The real MLflow 3.13.0 API fetched `http://172.30.49.10:9000/latest-meta-data` after a validation-safe HTTPS redirect and returned `PRUVA_INTERNAL_SSRF_CANARY_CVE_2026_64849` with `success: true` and `response_status: 200`.\n- **Parity:** `full`\n- **Not demonstrated:** The 307/308 blind-write variant and DNS-rebinding variant were not needed for impact parity and were not tested here. No code execution or privilege escalation is claimed.\n\n## Root Cause\n\nThree behaviors compose into the vulnerability:\n\n1. The default `mlflow server` does not load the optional authentication plugin, so webhook creation and test routes are reachable without credentials.\n2. `_validate_webhook_url` validates the original webhook hostname and rejects its initially resolved non-public addresses, but the validated address is not bound to subsequent connections.\n3. In 3.13.0, `_send_webhook_request` uses a normal Requests session, and `session.post(webhook.url, ...)` follows redirects by default. The redirect target is not passed back through `_validate_webhook_url`, so a public HTTPS first hop can redirect to RFC1918, loopback, or link-local HTTP.\n\nThe fix is upstream commit [`ba949522477cbd5915aa55d29b0cfad7d5ddf939`](https://github.com/mlflow/mlflow/commit/ba949522477cbd5915aa55d29b0cfad7d5ddf939), “Fix DNS-rebinding SSRF bypass in webhook delivery (#24258).” It introduces `SSRFProtectedHTTPAdapter`, validates the peer address of every actual connection (including a redirect connection), and disables environment proxy handling for the webhook session. MLflow 3.15.0 contains this code. In the fixed control, the initial HTTPS endpoint was successfully contacted, but connecting to the redirected private peer produced `SSRFProtectionError('Webhook connection blocked: 172.30.49.10 is not a public IP address...')`.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from any working directory. Docker, Git, curl, OpenSSL, and Python 3 are required.\n2. The script reads `bundle/project_cache_context.json`, reuses `<project_cache_dir>/repo` when prepared, verifies exact upstream tag SHAs and the fix hunk, and pulls official MLflow images:\n   - vulnerable: `ghcr.io/mlflow/mlflow@sha256:b2136d49f882fdc9c48bdb95514a8a23804e8756524135c3f09f7a553a3ac58b` (`mlflow.__version__ == 3.13.0`)\n   - fixed: `ghcr.io/mlflow/mlflow@sha256:2fef60dd85b18b4a555325b861b50b43ede45b1cc67176aa711cc86ad446a6a3` (`mlflow.__version__ == 3.15.0`)\n3. It creates two isolated Docker bridges: a public-class network containing a trusted HTTPS redirector at `93.184.216.34`, and an RFC1918 network containing a canary at `172.30.49.10` and MLflow at `172.30.49.20`.\n4. It launches the real default Tracking Server with SQLite and no auth plugin, waits for `/health`, creates a webhook through `POST /api/2.0/mlflow/webhooks`, and invokes `POST /api/2.0/mlflow/webhooks/{id}/test` without credentials.\n5. It requires the 3.13.0 response to contain the private canary and requires the private service request count to increase. It then performs the identical procedure on 3.15.0, requires connection-time private-peer rejection, and requires the private service request count not to increase.\n6. It writes `bundle/repro/runtime_manifest.json`, including SHA-256 closure over all immutable proof artifacts. Exit code 0 means the vulnerable/fixed behavioral delta was confirmed.\n\n## Evidence\n\n- **Runtime manifest:** `bundle/repro/runtime_manifest.json`\n- **Pinned identities:** `bundle/logs/repro/image-identities.txt`\n- **Vulnerable API request/response:**\n  - `bundle/logs/repro/vuln/test-wire.txt`\n  - `bundle/logs/repro/vuln/test-response.json`\n- **Fixed API request/response:**\n  - `bundle/logs/repro/fixed/test-wire.txt`\n  - `bundle/logs/repro/fixed/test-response.json`\n- **Production service logs:**\n  - `bundle/logs/repro/vuln/mlflow-service.log`\n  - `bundle/logs/repro/fixed/mlflow-service.log`\n- **Redirect and private-service evidence:**\n  - `bundle/logs/repro/vuln/redirector.log`\n  - `bundle/logs/repro/fixed/redirector.log`\n  - `bundle/logs/repro/final-canary.log`\n  - `bundle/logs/repro/{vuln,fixed}/canary-private-requests-{before,after}.txt`\n\nKey vulnerable response:\n\n```json\n{\n  \"result\": {\n    \"success\": true,\n    \"response_status\": 200,\n    \"response_body\": \"PRUVA_INTERNAL_SSRF_CANARY_CVE_2026_64849\\n\"\n  }\n}\n```\n\nThe private request counter changed from `0` to `1` during the vulnerable test. The fixed response instead contains:\n\n```text\nSSRFProtectionError('Webhook connection blocked: 172.30.49.10 is not a public IP address...')\n```\n\nThe fixed counter remained `1` before and after its test, while the redirector log gained the second `POST /redirect`; this proves the negative control trusted and reached the first hop but sent no HTTP request to the private service. Both final consecutive executions of the script passed. No sanitizer or instrumentation was used.\n\n## Recommendations / Next Steps\n\n- Upgrade to MLflow 3.15.0 or later.\n- Validate the actual peer of every connection, including redirects and retries, rather than only resolving and checking the original URL.\n- Ensure proxy configuration cannot bypass destination-peer validation, and preserve TLS verification against the original hostname.\n- Require authentication and authorization for webhook creation, modification, and testing even when MLflow is deployed with otherwise default settings.\n- Keep regression coverage for 301/302/303/307/308 redirects to loopback, RFC1918, link-local, IPv6-local, and mapped-address targets, plus DNS rebinding and proxy paths.\n\n## Additional Notes\n\nThe script is self-contained aside from standard tools and immutable remote images/repository objects that it fetches itself. It generates its own short-lived test CA and certificate, scopes trust to the MLflow test containers, creates fresh SQLite state, cleans child containers/networks on exit, and was verified twice consecutively in its final form. The public-class IP exists only inside an isolated Docker bridge; the private canary is a deterministic stand-in for an internal metadata endpoint, while all vulnerable logic and API behavior come from the unmodified official MLflow product image.\n","cve_id":"CVE-2026-64849","cwe_id":"CWE-918 Server-Side Request Forgery","source_url":"https://github.com/mlflow/mlflow","package":{"name":"mlflow/mlflow","ecosystem":"PyPI","affected_versions":"Official advisory range is mlflow < 3.15.0. The URL guard exists in 3.10.0 through 3.14.x and is bypassable by redirects or DNS rebinding; versions before 3.10.0 lack this guard and permit easier SSRF.","fixed_version":"mlflow==3.15.0"},"reproduced_at":"2026-08-23T15:44:22.550427+00:00","duration_secs":2463.0,"tool_calls":304,"handoffs":2,"total_cost_usd":9.997268,"agent_costs":{"claim_matcher":0.06143,"judge":0.620928,"learning_policy":0.013846,"repro":3.938997,"support":0.651992,"vuln_variant":4.710075},"cost_breakdown":{"claim_matcher":{"gpt-5.6-sol":0.06143},"judge":{"gpt-5.6-sol":0.620928},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.013846},"repro":{"gpt-5.6-sol":3.938997},"support":{"gpt-5.6-sol":0.651992},"vuln_variant":{"gpt-5.6-sol":4.710075}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:44:23.381204+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7489,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16484,"category":"reproduction_script"},{"path":"bundle/logs/repro/final-canary.log","filename":"final-canary.log","size":216,"category":"log"},{"path":"bundle/logs/repro/final-redirector.log","filename":"final-redirector.log","size":103,"category":"log"},{"path":"bundle/logs/repro/fixed/canary-private-requests-after.txt","filename":"canary-private-requests-after.txt","size":2,"category":"other"},{"path":"bundle/logs/repro/fixed/canary-private-requests-before.txt","filename":"canary-private-requests-before.txt","size":2,"category":"other"},{"path":"bundle/logs/repro/fixed/canary.log","filename":"canary.log","size":216,"category":"log"},{"path":"bundle/logs/repro/fixed/create-request.json","filename":"create-request.json","size":192,"category":"other"},{"path":"bundle/logs/repro/fixed/create-response.json","filename":"create-response.json","size":424,"category":"other"},{"path":"bundle/logs/repro/fixed/create-wire.txt","filename":"create-wire.txt","size":1682,"category":"other"},{"path":"bundle/logs/repro/fixed/mlflow-service.log","filename":"mlflow-service.log","size":1388,"category":"log"},{"path":"bundle/logs/repro/fixed/redirector.log","filename":"redirector.log","size":103,"category":"log"},{"path":"bundle/logs/repro/fixed/test-request.json","filename":"test-request.json","size":54,"category":"other"},{"path":"bundle/logs/repro/fixed/test-response.json","filename":"test-response.json","size":225,"category":"other"},{"path":"bundle/logs/repro/fixed/test-wire.txt","filename":"test-wire.txt","size":1299,"category":"other"},{"path":"bundle/logs/repro/image-identities.txt","filename":"image-identities.txt","size":372,"category":"other"},{"path":"bundle/logs/repro/vuln/canary-private-requests-after.txt","filename":"canary-private-requests-after.txt","size":2,"category":"other"},{"path":"bundle/logs/repro/vuln/canary-private-requests-before.txt","filename":"canary-private-requests-before.txt","size":2,"category":"other"},{"path":"bundle/logs/repro/vuln/canary.log","filename":"canary.log","size":216,"category":"log"},{"path":"bundle/logs/repro/vuln/create-request.json","filename":"create-request.json","size":191,"category":"other"},{"path":"bundle/logs/repro/vuln/create-response.json","filename":"create-response.json","size":423,"category":"other"},{"path":"bundle/logs/repro/vuln/create-wire.txt","filename":"create-wire.txt","size":1680,"category":"other"},{"path":"bundle/logs/repro/vuln/mlflow-service.log","filename":"mlflow-service.log","size":1064,"category":"log"},{"path":"bundle/logs/repro/vuln/redirector.log","filename":"redirector.log","size":60,"category":"log"},{"path":"bundle/logs/repro/vuln/test-request.json","filename":"test-request.json","size":54,"category":"other"},{"path":"bundle/logs/repro/vuln/test-response.json","filename":"test-response.json","size":137,"category":"other"},{"path":"bundle/logs/repro/vuln/test-wire.txt","filename":"test-wire.txt","size":1204,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":4799,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1237,"category":"other"}]}