Root cause
Repair foundation
Connected-peer validation and disabled environment proxies address ordinary redirects and rebinding in 3.15.0 source.
How AI contributed: Flawed AI-written codePublished Aug 17, 2026 in
mlflow/mlflowAll 7 findings in this repository
Provisional finding. Full causal and release verification remains incomplete.
Original-URL checks neither constrain redirects nor bind connection-time DNS. The 3.15.0 source adds connected-peer protection; 3.16.0 also normalizes the documented IPv6-transition formats. Client access, authentication/store mode, target protocol behavior and private-IP opt-in remain material.
AI-assisted change
The generated request/test component adds capped response readback; activation and later uncapping remain distinct human contributions.
Root cause
Connected-peer validation and disabled environment proxies address ordinary redirects and rebinding in 3.15.0 source.
Fix
Normalization in both preflight and peer checks completes the documented address-format scope in 3.16.0 source.
Fix authorship not yet analyzed
Case facts
diff --git a/mlflow/webhooks/dispatch.py b/mlflow/webhooks/dispatch.pyindex 2e33d5a9..15354179 100644--- a/mlflow/webhooks/dispatch.py+++ b/mlflow/webhooks/dispatch.py@@ -2,10 +2,11 @@ import hashlib import hmac import json import logging+from typing import Optional import requests -from mlflow.entities.webhook import WebhookEvent+from mlflow.entities.webhook import WebhookEvent, WebhookTestResult from mlflow.store.model_registry.abstract_store import AbstractStore from mlflow.webhooks.constants import WEBHOOK_SIGNATURE_HEADER from mlflow.webhooks.types import WebhookPayload@@ -27,6 +28,44 @@ def _generate_hmac_signature(secret: str, payload_bytes: bytes) -> str: return f"sha256={signature}" +def _send_webhook_request(+ url: str,+ payload: WebhookPayload,+ secret: Optional[str] = None,+) -> WebhookTestResult:+ """Send a webhook request to the specified URL.++ Args:+ url: The webhook URL to send the request to+ payload: The payload to send+ secret: Optional secret for HMAC signature++ Returns:+ WebhookTestResult indicating success/failure and response details+ """+ try:+ payload_bytes = json.dumps(payload).encode("utf-8")+ headers = {"Content-Type": "application/json"}++ # Add HMAC signature if secret is configured+ if secret:+ signature = _generate_hmac_signature(secret, payload_bytes)+ headers[WEBHOOK_SIGNATURE_HEADER] = signature++ response = requests.post(url, data=payload_bytes, headers=headers, timeout=30)++ return WebhookTestResult(+ success=response.status_code < 400,+ response_status=response.status_code,+ response_body=response.text[:1000] if response.text else None, # Truncate response+ )+ except Exception as e:+ return WebhookTestResult(+ success=False,+ error_message=str(e)[:500], # Truncate error message+ )++ def _dispatch_webhook_impl( *, event: WebhookEvent,@@ -36,14 +75,7 @@ def _dispatch_webhook_impl( # TODO: Make this non-blocking for webhook in store.list_webhooks(): if event in webhook.events:- payload_bytes = json.dumps(payload).encode("utf-8")- headers = {"Content-Type": "application/json"}- # Add HMAC signature if secret is configured- if webhook.secret:- signature = _generate_hmac_signature(webhook.secret, payload_bytes)- headers[WEBHOOK_SIGNATURE_HEADER] = signature-- requests.post(webhook.url, data=payload_bytes, headers=headers)+ _send_webhook_request(webhook.url, payload, webhook.secret) def dispatch_webhook(@@ -59,3 +91,61 @@ def dispatch_webhook( f"Failed to dispatch webhook for event {event}: {e}", exc_info=True, )+++def test_webhook(+ webhook_id: str, store: AbstractStore, event: Optional[WebhookEvent] = None+) -> WebhookTestResult:+ """Test a webhook by sending a test payload.++ Args:+ webhook_id: The ID of the webhook to test+ store: The model registry store to retrieve webhook details+ event: Optional event type to test. If not specified, uses the first event from webhook.++ Returns:+ WebhookTestResult indicating success/failure and response details+ """+ try:+ webhook = store.get_webhook(webhook_id)++ # Use provided event or the first event type for testing+ test_event = event or webhook.events[0]++ # Generate example payload based on the event type+ if test_event == WebhookEvent.REGISTERED_MODEL_CREATED:+ from mlflow.webhooks.types import RegisteredModelCreatedPayload++ test_payload = RegisteredModelCreatedPayload.example()+ elif test_event == WebhookEvent.MODEL_VERSION_CREATED:+ from mlflow.webhooks.types import ModelVersionCreatedPayload++ test_payload = ModelVersionCreatedPayload.example()+ elif test_event == WebhookEvent.MODEL_VERSION_TAG_SET:+ from mlflow.webhooks.types import ModelVersionTagSetPayload++ test_payload = ModelVersionTagSetPayload.example()+ elif test_event == WebhookEvent.MODEL_VERSION_TAG_DELETED:+ from mlflow.webhooks.types import ModelVersionTagDeletedPayload++ test_payload = ModelVersionTagDeletedPayload.example()+ elif test_event == WebhookEvent.MODEL_VERSION_ALIAS_CREATED:+ from mlflow.webhooks.types import ModelVersionAliasCreatedPayload++ test_payload = ModelVersionAliasCreatedPayload.example()+ elif test_event == WebhookEvent.MODEL_VERSION_ALIAS_DELETED:+ from mlflow.webhooks.types import ModelVersionAliasDeletedPayload++ test_payload = ModelVersionAliasDeletedPayload.example()+ else:+ # Default to MODEL_VERSION_CREATED payload+ from mlflow.webhooks.types import ModelVersionCreatedPayload++ test_payload = ModelVersionCreatedPayload.example()++ return _send_webhook_request(webhook.url, test_payload, webhook.secret)+ except Exception as e:+ return WebhookTestResult(+ success=False,+ error_message=f"Failed to test webhook: {str(e)[:500]}",+ )diff --git a/mlflow/utils/validation.py b/mlflow/utils/validation.pyindex 7a34ed15..db369692 100644--- a/mlflow/utils/validation.py+++ b/mlflow/utils/validation.py@@ -898,6 +898,32 @@ def _validate_webhook_name(name: str) -> None: ) +_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")+++def _embedded_ipv4(+ ip: ipaddress.IPv4Address | ipaddress.IPv6Address,+) -> ipaddress.IPv4Address | ipaddress.IPv6Address:+ """Decode IPv6 transition formats that carry an embedded IPv4 address.++ This normalizes IPv6-transition addresses (IPv4-mapped, 6to4, NAT64) to their+ embedded IPv4 address so that a wrapped private/CGNAT/link-local address cannot+ masquerade as global.+ """+ if isinstance(ip, ipaddress.IPv6Address):+ embedded = ip.ipv4_mapped or ip.sixtofour+ if embedded is None and ip in _NAT64_PREFIX:+ embedded = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)+ if embedded is not None:+ return embedded+ return ip+++def _is_public_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:+ """Check if an IP is public, accounting for IPv6-transition address formats."""+ return _embedded_ipv4(ip).is_global++ def _resolve_hostname_with_timeout(hostname: str, field_name: str): acquired = _HOSTNAME_RESOLUTION_SEMAPHORE.acquire(timeout=_HOSTNAME_RESOLUTION_TIMEOUT_SECONDS) if not acquired:@@ -944,7 +970,7 @@ def _validate_hostname_resolves_to_public_ips(hostname: str, field_name: str) -> raise MlflowException.invalid_parameter_value( f"{field_name} hostname {hostname!r} resolved to an invalid IP address: {e}" ) from e- if not ip.is_global:+ if not _is_public_ip(ip): raise MlflowException.invalid_parameter_value( f"{field_name} must not resolve to a non-public IP address. " f"{hostname!r} resolves to {ip}."diff --git a/mlflow/webhooks/ssrf.py b/mlflow/webhooks/ssrf.pyindex f5133aac..e619a20f 100644--- a/mlflow/webhooks/ssrf.py+++ b/mlflow/webhooks/ssrf.py@@ -27,6 +27,7 @@ from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool from urllib3.poolmanager import PoolManager, ProxyManager from mlflow.environment_variables import _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS+from mlflow.utils.validation import _is_public_ip class SSRFProtectionError(Exception):@@ -58,7 +59,7 @@ def _assert_public_peer(sock: socket.socket) -> None: f"Webhook connection resolved to an invalid IP address: {peer_ip!r}" ) from e - if not ip.is_global:+ if not _is_public_ip(ip): sock.close() raise SSRFProtectionError( f"Webhook connection blocked: {ip} is not a public IP address. "AI-assisted change ebed4025da097bc71a268639200ae64b081a2b7b463e1d22c54758576f38dde3 · Fix baee04a055e50efb70f3002480c60db0bce9b04204d7da506ce306ba3fde567f