# SCAN: mlflow date: 2026-02-12 | program: MLflow | repo: https://github.com/mlflow/mlflow | bounty: $500-$1500 | platform: huntr ## summary raw_findings: ~25 (manual scan) | real: 2 | high_conf: 0 | med_conf: 2 | low_conf: 0 | false_pos: ~23 NOTE: semgrep/trufflehog could not be run due to sandbox restrictions. This scan was performed via targeted manual code review of security-sensitive areas: server handlers, auth, SQL stores, artifact handling, search filters, webhook delivery, gateway proxy, scorer deserialization, and job execution. ## findings ### F1: Source Validation Bypass via Prompt Tag in CreateModelVersion severity: medium | confidence: medium | type: path-traversal / auth-bypass | cwe: CWE-284 file: /Users/sebas/Code/bug-bounty/data/repos/mlflow/mlflow/server/handlers.py:2648 | tool: manual ```python # handlers.py:2647-2653 # If the model version is a prompt, we don't validate the source is_prompt = _is_prompt_request(request_message) if not is_prompt: if request_message.model_id: _validate_source_model(request_message.source, request_message.model_id) else: _validate_source_run(request_message.source, request_message.run_id) # handlers.py:2712-2713 def _is_prompt_request(request_message): return any(tag.key == IS_PROMPT_TAG_KEY for tag in request_message.tags) # IS_PROMPT_TAG_KEY = "mlflow.prompt.is_prompt" ``` analysis: When creating a model version, the source field normally undergoes strict validation via _validate_source_run or _validate_source_model, which prevents path traversal and arbitrary local file path usage. However, if the request includes a tag with key "mlflow.prompt.is_prompt", all source validation is bypassed. The source field is then stored as-is in the database. Subsequent calls to get_model_version_download_uri return this unvalidated source, which is used by get_model_version_artifact_handler to construct an artifact repository and serve files. On a default MLflow server deployment (no auth module), this is exploitable by any network-reachable client. attack_vector: POST /api/2.0/mlflow/model-versions/create with body {"name": "existing_model", "source": "file:///etc/passwd", "tags": [{"key": "mlflow.prompt.is_prompt", "value": "true"}]}. Then read via GET /model-versions/get-artifact?name=name&version=version&path=filename. impact: Arbitrary file read on the tracking server host. On default deployments (no auth), any network-reachable client can read server-side files. caveats: (1) The registered model must already exist. (2) The artifact handler path still goes through validate_path_is_safe on the path parameter, but the artifact_uri itself (derived from source) is unvalidated. (3) Whether get_artifact_repository(artifact_uri) resolves local file URIs and _send_artifact successfully reads them depends on the artifact repo implementation for file:// scheme. (4) If auth is enabled, the attacker needs write permission on the model. duplicate_risk: medium -- MLflow has had prior path traversal CVEs (CVE-2023-6909, CVE-2024-0520). This specific prompt-tag bypass may be novel since webhooks/prompts appear relatively new. recommendation: INVESTIGATE --- ### F2: SSRF via Webhook URL (Restricted to HTTPS by Default) severity: low | confidence: medium | type: ssrf | cwe: CWE-918 file: /Users/sebas/Code/bug-bounty/data/repos/mlflow/mlflow/webhooks/delivery.py:183 | tool: manual ```python # delivery.py:183 return session.post(webhook.url, data=payload_bytes, headers=headers, timeout=timeout) # validation.py:718-738 def _validate_webhook_url(url): schemes = _MLFLOW_WEBHOOK_ALLOWED_SCHEMES.get() # default: ["https"] if parsed_url.scheme not in schemes: raise MlflowException.invalid_parameter_value(...) ``` analysis: The webhook system allows users to register arbitrary URLs that the MLflow server will POST to when events occur. The URL validation only checks the scheme (default: HTTPS only) and does not validate the hostname against an allowlist, does not block internal/private IP ranges (127.0.0.1, 10.x, 169.254.x, etc.), and does not use DNS rebinding protection. An authenticated user (when auth is enabled) or any user (on default deployments) can create webhooks pointing to internal services accessible from the MLflow server. attack_vector: POST /api/2.0/mlflow/webhooks/create with {"name": "test", "url": "https://internal-service.corp:8443/api/v1/sensitive", "events": [{"entity": "MODEL_VERSION", "action": "CREATED"}]}. Then trigger the event. The server will POST to the internal URL. impact: SSRF limited to HTTPS POST requests (by default). Can probe internal HTTPS services, potentially exfiltrate data via the event payload structure. Impact limited by: (1) HTTPS-only default, (2) POST-only, (3) fixed payload structure. recommendation: SKIP -- low severity due to HTTPS-only default and POST-only limitation. --- ## skipped | file:line | pattern | reason | |-----------|---------|--------| | genai/scorers/scorer_utils.py:150 | exec(func_def) | Guarded by is_in_databricks_runtime() check. Only runs in Databricks. | | store/tracking/sqlalchemy_store.py:432 | sql.text(f"INSERT...") | Not user-controlled. Hardcoded default_experiment values. | | server/handlers.py:2057 | requests.request(...gateway_path...) | gateway_path strict-regex validated. target_uri from env var. | | server/jobs/_job_subproc_entry.py:37 | _load_function(env_var) | Validated against _ALLOWED_JOB_NAME_LIST. Not user input. | | server/jobs/_job_subproc_entry.py:47 | cloudpickle.load(f) | Server-controlled temp file path. | | pyfunc/model.py:845 | cloudpickle.load(f) | Client-side model loading. | | sklearn/__init__.py:544 | pickle.load(f) | Client-side model loading. | | pytorch/__init__.py:643 | torch.load(model_path) | Client-side model loading. | | utils/yaml_utils.py:106 | yaml.load(...SafeLoader) | Uses SafeLoader. | | transformers/__init__.py:2176 | ast.literal_eval(s) | literal_eval is safe. | | utils/search_utils.py:1010 | ast.literal_eval(token) | literal_eval is safe. | | server/auth/__init__.py:2235 | render_template_string | Jinja2 auto-escapes. href is constant. | | projects/utils.py:243 | requests.get(uri) | Client-side, not server endpoint. | | server/assistant/api.py | All endpoints | Restricted to localhost via _require_localhost. | | server/handlers.py:3228 | _download_artifact | Protected by validate_path_is_safe. | | server/handlers.py:3255 | _upload_artifact | Protected by validate_path_is_safe. | | utils/uri.py:487 | validate_path_is_safe | Security guard itself. Appears robust. | | server/handlers.py:2534 | _validate_non_local_source | URL-decode loop + resolve + null check. Robust. | | model_registry/dbmodels/models.py:46 | sa.text(f"'constant'") | Constant value. | | server/handlers.py:967 | _send_artifact | Uses abspath + send_file + as_attachment + nosniff. | | server/handlers.py:2025 | _validate_gateway_path | Strict regex validation. |