Files
2026-07-13 13:22:34 +08:00

127 lines
4.5 KiB
Python

import logging
from http import HTTPStatus
from flask import Flask, Response, request
from flask_cors import CORS
from mlflow.environment_variables import (
MLFLOW_SERVER_DISABLE_SECURITY_MIDDLEWARE,
MLFLOW_SERVER_X_FRAME_OPTIONS,
)
from mlflow.server.security_utils import (
CORS_BLOCKED_MSG,
HEALTH_ENDPOINTS,
INVALID_HOST_MSG,
LOCALHOST_ORIGIN_PATTERNS,
get_allowed_hosts_from_env,
get_allowed_origins_from_env,
get_default_allowed_hosts,
is_allowed_host_header,
is_api_endpoint,
should_block_cors_request,
)
from mlflow.tracing.constant import TRACE_RENDERER_ASSET_PATH
_logger = logging.getLogger(__name__)
def get_allowed_hosts() -> list[str]:
"""Get list of allowed hosts from environment or defaults."""
return get_allowed_hosts_from_env() or get_default_allowed_hosts()
def get_allowed_origins() -> list[str]:
"""Get list of allowed CORS origins from environment or defaults."""
return get_allowed_origins_from_env() or []
def init_security_middleware(app: Flask) -> None:
"""
Initialize security middleware for Flask application.
This configures:
- Host header validation (DNS rebinding protection)
- CORS protection via Flask-CORS
- Security headers
Args:
app: Flask application instance.
"""
if MLFLOW_SERVER_DISABLE_SECURITY_MIDDLEWARE.get() == "true":
return
allowed_origins = get_allowed_origins()
allowed_hosts = get_allowed_hosts()
x_frame_options = MLFLOW_SERVER_X_FRAME_OPTIONS.get()
if allowed_origins and "*" in allowed_origins:
_logger.warning(
"MLFLOW_SERVER_CORS_ALLOWED_ORIGINS=* is set; disabling credentialed CORS. "
"Wildcard origins with credentials is a CORS spec violation. "
"Set an explicit origin allowlist to enable credentialed cross-origin requests."
)
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=False)
else:
cors_origins = (allowed_origins or []) + LOCALHOST_ORIGIN_PATTERNS
CORS(
app,
resources={r"/*": {"origins": cors_origins}},
supports_credentials=True,
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
)
if allowed_hosts and "*" not in allowed_hosts:
@app.before_request
def validate_host():
if request.path in HEALTH_ENDPOINTS:
return None
if not is_allowed_host_header(allowed_hosts, host := request.headers.get("Host")):
_logger.warning(f"Rejected request with invalid Host header: {host}")
return Response(
INVALID_HOST_MSG, status=HTTPStatus.FORBIDDEN, mimetype="text/plain"
)
return None
# The block_cross_origin_state_changes hook is intentionally skipped when the
# admin sets MLFLOW_SERVER_CORS_ALLOWED_ORIGINS=*: server-side rejection of
# cross-origin requests would defeat the purpose of wildcard mode. The
# browser-side guardrail is that supports_credentials=False above causes
# browsers to strip cookies/Authorization headers on cross-origin requests,
# so authenticated endpoints still 401.
if not (allowed_origins and "*" in allowed_origins):
@app.before_request
def block_cross_origin_state_changes():
if not is_api_endpoint(request.path):
return None
origin = request.headers.get("Origin")
if should_block_cors_request(origin, request.method, allowed_origins):
_logger.warning(f"Blocked cross-origin request from {origin}")
return Response(
CORS_BLOCKED_MSG, status=HTTPStatus.FORBIDDEN, mimetype="text/plain"
)
return None
@app.after_request
def add_security_headers(response: Response) -> Response:
response.headers["X-Content-Type-Options"] = "nosniff"
# Skip X-Frame-Options for notebook-trace-renderer to allow iframe embedding in Jupyter
is_notebook_renderer = request.path.startswith(TRACE_RENDERER_ASSET_PATH)
if x_frame_options and x_frame_options.upper() != "NONE" and not is_notebook_renderer:
response.headers["X-Frame-Options"] = x_frame_options.upper()
if (
request.method == "OPTIONS"
and response.status_code == 200
and is_api_endpoint(request.path)
):
response.status_code = HTTPStatus.NO_CONTENT
response.data = b""
return response