-
Notifications
You must be signed in to change notification settings - Fork 772
Fix gateway URL resolution #443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -335,12 +335,19 @@ async def _relay_notify(request: Request): | |
|
|
||
| # Optional shared-secret auth | ||
| gw_token = os.environ.get("MCP_GATEWAY_TOKEN") | ||
| if gw_token and not secrets.compare_digest( | ||
| request.headers.get("X-MCP-Gateway-Token", ""), gw_token | ||
| ): | ||
| return JSONResponse( | ||
| {"ok": False, "error": "unauthorized"}, status_code=401 | ||
| if gw_token: | ||
| bearer = request.headers.get("Authorization", "") | ||
| bearer_token = ( | ||
| bearer.split(" ", 1)[1] if bearer.lower().startswith("bearer ") else "" | ||
| ) | ||
| header_tok = request.headers.get("X-MCP-Gateway-Token", "") | ||
| if not ( | ||
| secrets.compare_digest(header_tok, gw_token) | ||
| or secrets.compare_digest(bearer_token, gw_token) | ||
| ): | ||
| return JSONResponse( | ||
| {"ok": False, "error": "unauthorized"}, status_code=401 | ||
| ) | ||
|
|
||
| # Optional idempotency handling | ||
| idempotency_key = params.get("idempotency_key") | ||
|
|
@@ -395,6 +402,9 @@ async def _relay_notify(request: Request): | |
|
|
||
| return JSONResponse({"ok": True}) | ||
| except Exception as e: | ||
| # After workflow cleanup, upstream sessions may be closed. Treat notify as best-effort. | ||
| if isinstance(method, str) and method.startswith("notifications/"): | ||
| return JSONResponse({"ok": True, "dropped": True}) | ||
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | ||
|
|
||
| @mcp_server.custom_route( | ||
|
|
@@ -499,12 +509,19 @@ async def _internal_workflows_log(request: Request): | |
|
|
||
| # Optional shared-secret auth | ||
| gw_token = os.environ.get("MCP_GATEWAY_TOKEN") | ||
| if gw_token and not secrets.compare_digest( | ||
| request.headers.get("X-MCP-Gateway-Token", ""), gw_token | ||
| ): | ||
| return JSONResponse( | ||
| {"ok": False, "error": "unauthorized"}, status_code=401 | ||
| if gw_token: | ||
| bearer = request.headers.get("Authorization", "") | ||
| bearer_token = ( | ||
| bearer.split(" ", 1)[1] if bearer.lower().startswith("bearer ") else "" | ||
| ) | ||
| header_tok = request.headers.get("X-MCP-Gateway-Token", "") | ||
| if not ( | ||
| secrets.compare_digest(header_tok, gw_token) | ||
| or secrets.compare_digest(bearer_token, gw_token) | ||
| ): | ||
| return JSONResponse( | ||
| {"ok": False, "error": "unauthorized"}, status_code=401 | ||
| ) | ||
|
|
||
| session = await _get_session(execution_id) | ||
| if not session: | ||
|
|
@@ -538,10 +555,17 @@ async def _internal_human_prompts(request: Request): | |
|
|
||
| # Optional shared-secret auth | ||
| gw_token = os.environ.get("MCP_GATEWAY_TOKEN") | ||
| if gw_token and not secrets.compare_digest( | ||
| request.headers.get("X-MCP-Gateway-Token", ""), gw_token | ||
| ): | ||
| return JSONResponse({"error": "unauthorized"}, status_code=401) | ||
| if gw_token: | ||
| bearer = request.headers.get("Authorization", "") | ||
| bearer_token = ( | ||
| bearer.split(" ", 1)[1] if bearer.lower().startswith("bearer ") else "" | ||
| ) | ||
| header_tok = request.headers.get("X-MCP-Gateway-Token", "") | ||
| if not ( | ||
| secrets.compare_digest(header_tok, gw_token) | ||
| or secrets.compare_digest(bearer_token, gw_token) | ||
| ): | ||
| return JSONResponse({"error": "unauthorized"}, status_code=401) | ||
|
|
||
| session = await _get_session(execution_id) | ||
| if not session: | ||
|
|
@@ -1367,38 +1391,61 @@ async def _workflow_run( | |
| # Build memo for Temporal runs if gateway info is available | ||
| workflow_memo = None | ||
| try: | ||
| # Prefer explicit kwargs, else infer from request headers/environment | ||
| # FastMCP keeps raw request under ctx.request_context.request if available | ||
| # Prefer explicit kwargs, else infer from request context/headers | ||
| gateway_url = kwargs.get("gateway_url") | ||
| gateway_token = kwargs.get("gateway_token") | ||
|
|
||
| if gateway_url is None: | ||
| try: | ||
| req = getattr(ctx.request_context, "request", None) | ||
| if req is not None: | ||
| # Custom header if present | ||
| h = req.headers | ||
| gateway_url = ( | ||
| h.get("X-MCP-Gateway-URL") | ||
| or h.get("X-Forwarded-Url") | ||
| or h.get("X-Forwarded-Proto") | ||
| ) | ||
| # Best-effort reconstruction if only proto/host provided | ||
| if gateway_url is None: | ||
| proto = h.get("X-Forwarded-Proto") or "http" | ||
| host = h.get("X-Forwarded-Host") or h.get("Host") | ||
| if host: | ||
| gateway_url = f"{proto}://{host}" | ||
| except Exception: | ||
| pass | ||
| req = getattr(ctx.request_context, "request", None) | ||
| if req is not None: | ||
| h = req.headers | ||
| # Highest precedence: caller-provided full base URL | ||
| header_url = h.get("X-MCP-Gateway-URL") or h.get("X-Forwarded-Url") | ||
| if gateway_url is None and header_url: | ||
| gateway_url = header_url | ||
|
|
||
| # Token may be provided by the gateway/proxy | ||
| if gateway_token is None: | ||
| gateway_token = h.get("X-MCP-Gateway-Token") | ||
| if gateway_token is None: | ||
| # Support Authorization: Bearer <token> | ||
| auth = h.get("Authorization") | ||
| if auth and auth.lower().startswith("bearer "): | ||
| gateway_token = auth.split(" ", 1)[1] | ||
|
|
||
| # Prefer explicit reconstruction from X-Forwarded-* if present | ||
| if gateway_url is None and (h.get("X-Forwarded-Host") or h.get("Host")): | ||
| proto = h.get("X-Forwarded-Proto") or "http" | ||
| host = h.get("X-Forwarded-Host") or h.get("Host") | ||
| prefix = h.get("X-Forwarded-Prefix") or "" | ||
| if prefix and not prefix.startswith("/"): | ||
| prefix = "/" + prefix | ||
| if host: | ||
| gateway_url = f"{proto}://{host}{prefix}" | ||
|
|
||
| # Fallback to request's base_url which already includes scheme/host and any mount prefix | ||
| if gateway_url is None: | ||
| try: | ||
| if getattr(req, "base_url", None): | ||
| base_url = str(req.base_url).rstrip("/") | ||
| if base_url and base_url.lower() != "none": | ||
| gateway_url = base_url | ||
| except Exception: | ||
| gateway_url = None | ||
|
|
||
| if gateway_token is None: | ||
| try: | ||
| req = getattr(ctx.request_context, "request", None) | ||
| if req is not None: | ||
| gateway_token = req.headers.get("X-MCP-Gateway-Token") | ||
| except Exception: | ||
| pass | ||
| # Final fallback: environment variables (useful if proxies don't set headers) | ||
| try: | ||
| import os as _os | ||
|
|
||
| if gateway_url is None: | ||
| env_url = _os.environ.get("MCP_GATEWAY_URL") | ||
| if env_url: | ||
| gateway_url = env_url | ||
| if gateway_token is None: | ||
| env_tok = _os.environ.get("MCP_GATEWAY_TOKEN") | ||
| if env_tok: | ||
| gateway_token = env_tok | ||
| except Exception: | ||
| pass | ||
|
Comment on lines
+1394
to
+1448
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not trust client-supplied gateway_url; gate on valid token and validate URL (SSRF/data exfil risk). A malicious client can set X-MCP-Gateway-URL/X-Forwarded-* and steer worker callbacks (with potential sensitive logs/prompts) to an attacker-controlled URL. Only accept header-derived URLs when a valid gateway token accompanies the request; otherwise derive from request.base_url. Also normalize/validate scheme/host. # Prefer explicit kwargs, else infer from request context/headers
gateway_url = kwargs.get("gateway_url")
gateway_token = kwargs.get("gateway_token")
req = getattr(ctx.request_context, "request", None)
if req is not None:
h = req.headers
# Highest precedence: caller-provided full base URL
header_url = h.get("X-MCP-Gateway-URL") or h.get("X-Forwarded-Url")
- if gateway_url is None and header_url:
- gateway_url = header_url
+ # Trust header URL only if caller presents a valid gateway token
+ env_tok = os.environ.get("MCP_GATEWAY_TOKEN")
+ trusted_headers = False
+ if gateway_token:
+ try:
+ trusted_headers = bool(env_tok and secrets.compare_digest(gateway_token, env_tok))
+ except Exception:
+ trusted_headers = False
+ if gateway_url is None and header_url and trusted_headers:
+ gateway_url = header_url
# Token may be provided by the gateway/proxy
if gateway_token is None:
gateway_token = h.get("X-MCP-Gateway-Token")
if gateway_token is None:
# Support Authorization: Bearer <token>
auth = h.get("Authorization")
if auth and auth.lower().startswith("bearer "):
- gateway_token = auth.split(" ", 1)[1]
+ gateway_token = auth.split(" ", 1)[1].strip()
# Prefer explicit reconstruction from X-Forwarded-* if present
- if gateway_url is None and (h.get("X-Forwarded-Host") or h.get("Host")):
+ if gateway_url is None and trusted_headers and (h.get("X-Forwarded-Host") or h.get("Host")):
proto = h.get("X-Forwarded-Proto") or "http"
host = h.get("X-Forwarded-Host") or h.get("Host")
prefix = h.get("X-Forwarded-Prefix") or ""
if prefix and not prefix.startswith("/"):
prefix = "/" + prefix
if host:
gateway_url = f"{proto}://{host}{prefix}"
# Fallback to request's base_url which already includes scheme/host and any mount prefix
if gateway_url is None:
try:
if getattr(req, "base_url", None):
base_url = str(req.base_url).rstrip("/")
if base_url and base_url.lower() != "none":
gateway_url = base_url
except Exception:
gateway_url = None
+
+ # Normalize and validate the URL (only http/https with netloc)
+ if isinstance(gateway_url, str):
+ u = gateway_url.strip().rstrip("/")
+ try:
+ from urllib.parse import urlsplit, urlunsplit
+ parts = urlsplit(u)
+ if parts.scheme not in ("http", "https") or not parts.netloc:
+ gateway_url = None
+ else:
+ gateway_url = urlunsplit((parts.scheme, parts.netloc, parts.path or "", "", ""))
+ except Exception:
+ gateway_url = None
# Final fallback: environment variables (useful if proxies don't set headers)
try:
- import os as _os
-
if gateway_url is None:
- env_url = _os.environ.get("MCP_GATEWAY_URL")
+ env_url = os.environ.get("MCP_GATEWAY_URL")
if env_url:
gateway_url = env_url
if gateway_token is None:
- env_tok = _os.environ.get("MCP_GATEWAY_TOKEN")
- if env_tok:
- gateway_token = env_tok
+ if env_tok:
+ gateway_token = env_tok
except Exception:
pass
🤖 Prompt for AI Agents |
||
|
|
||
| if gateway_url or gateway_token: | ||
| workflow_memo = { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.