Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,50 @@ def validate_password_strength(password: str) -> tuple[bool, str]:
ADMIN_CSRF_FORM_FIELD = "csrf_token"


def _resolve_root_path(request: Request) -> str:
"""Resolve the application root path from the request scope with fallback.

Some embedded/proxy deployments do not populate ``scope["root_path"]``
consistently. This helper checks the ASGI scope first and falls back
to ``settings.app_root_path`` when the scope value is empty.

Args:
request: Incoming request used to read ASGI ``root_path``.

Returns:
Normalized root path (leading ``/``, no trailing ``/``), or empty
string when no root path is configured.
"""
root_path = request.scope.get("root_path", "") or ""
if not root_path or not str(root_path).strip():
root_path = settings.app_root_path or ""
root_path = str(root_path).strip()
if root_path:
root_path = "/" + root_path.lstrip("/")
return root_path.rstrip("/")


def _is_safe_local_path(path: str) -> bool:
"""Validate that a path is a safe local redirect target (no open redirect).

Args:
path: The path to validate.

Returns:
True if the path is a safe relative path starting with ``/``.
"""
if not path or not isinstance(path, str):
return False
if not path.startswith("/"):
return False
# Block protocol-relative URLs (//evil.com), authority injection (@), backslash tricks
if path.startswith("//") or "@" in path or "\\" in path:
return False
parsed = urllib.parse.urlparse(path)
if parsed.scheme or parsed.netloc:
return False
return True

def _admin_cookie_path(request: Request) -> str:
"""Build admin cookie path honoring ASGI root_path.

Expand Down Expand Up @@ -2928,6 +2972,8 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
visibility=visibility,
oauth_enabled=oauth_enabled,
oauth_config=oauth_config,
server_type="meta" if form.get("meta_server_enabled") else str(form.get("server_type", "standard")),
hide_underlying_tools=form.get("hide_underlying_tools") == "true" or form.get("hide_underlying_tools") == "on",
)
except KeyError as e:
# Convert KeyError to ValidationError-like response
Expand Down Expand Up @@ -3091,6 +3137,8 @@ async def admin_edit_server(
owner_email=user_email,
oauth_enabled=oauth_enabled,
oauth_config=oauth_config,
server_type="meta" if form.get("meta_server_enabled") else str(form.get("server_type", "standard")),
hide_underlying_tools=form.get("hide_underlying_tools") == "true" or form.get("hide_underlying_tools") == "on",
)

await server_service.update_server(
Expand Down Expand Up @@ -4161,6 +4209,21 @@ async def admin_login_page(request: Request) -> Response:
response.delete_cookie("jwt_token", path="/")
response.delete_cookie("access_token", path="/")

# Preserve ?next= parameter as a short-lived cookie so SSO callback can redirect
# back to the original URL (e.g. /oauth/authorize/{gateway_id}) after login.
next_url = request.query_params.get("next", "")
if next_url and _is_safe_local_path(next_url):
use_secure = (settings.environment == "production") or settings.secure_cookies
response.set_cookie(
key="post_login_next",
value=next_url,
max_age=300, # 5 minutes β€” enough for SSO round-trip
httponly=True,
secure=use_secure,
samesite=settings.cookie_samesite,
path=settings.app_root_path or "/",
)

return response


Expand Down
22 changes: 22 additions & 0 deletions mcpgateway/admin_ui/formSubmitHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ export const handleServerFormSubmit = async function (e) {
}
}

// Handle Meta-Server configuration
const metaEnabledCheckbox = safeGetElement("server-meta-enabled");
if (metaEnabledCheckbox && metaEnabledCheckbox.checked) {
formData.set("server_type", "meta");
const hideToolsCheckbox = safeGetElement("server-hide-underlying-tools");
formData.set("hide_underlying_tools", hideToolsCheckbox && hideToolsCheckbox.checked ? "true" : "false");
} else {
formData.set("server_type", "standard");
formData.delete("hide_underlying_tools");
}

const response = await fetch(`${window.ROOT_PATH}/admin/servers`, {
method: "POST",
body: formData,
Expand Down Expand Up @@ -1008,6 +1019,17 @@ export const handleEditServerFormSubmit = async function (e) {
}
});

// Handle Meta-Server configuration
const metaEnabledCheckbox = safeGetElement("edit-server-meta-enabled");
if (metaEnabledCheckbox && metaEnabledCheckbox.checked) {
formData.set("server_type", "meta");
const hideToolsCheckbox = safeGetElement("edit-server-hide-underlying-tools");
formData.set("hide_underlying_tools", hideToolsCheckbox && hideToolsCheckbox.checked ? "true" : "false");
} else {
formData.set("server_type", "standard");
formData.delete("hide_underlying_tools");
}

// Submit via fetch
const response = await fetch(form.action, {
method: "POST",
Expand Down
41 changes: 41 additions & 0 deletions mcpgateway/admin_ui/servers.js
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,47 @@ export const editServer = async function (serverId) {
if (oauthTokenEndpointField) oauthTokenEndpointField.value = "";
}

// Set Meta-Server configuration fields
const metaEnabledCheckbox = safeGetElement("edit-server-meta-enabled");
const metaConfigSection = safeGetElement("edit-server-meta-config-section");
const hideUnderlyingToolsCheckbox = safeGetElement("edit-server-hide-underlying-tools");
const isMeta = server.serverType === "meta" || server.server_type === "meta";

if (metaEnabledCheckbox) {
metaEnabledCheckbox.checked = isMeta;
}
if (metaConfigSection) {
if (isMeta) {
metaConfigSection.classList.remove("hidden");
} else {
metaConfigSection.classList.add("hidden");
}
}
if (hideUnderlyingToolsCheckbox) {
const hideTools = server.hideUnderlyingTools !== undefined
? server.hideUnderlyingTools
: (server.hide_underlying_tools !== undefined ? server.hide_underlying_tools : true);
hideUnderlyingToolsCheckbox.checked = isMeta ? hideTools : true;
}

// Toggle gateways+tools wrapper and info banner based on meta-server mode
const editGatewaysAndTools = safeGetElement("edit-server-gateways-and-tools");
const editMetaInfoBanner = safeGetElement("edit-meta-info-banner");
if (editGatewaysAndTools) {
if (isMeta) {
editGatewaysAndTools.classList.add("hidden");
} else {
editGatewaysAndTools.classList.remove("hidden");
}
}
if (editMetaInfoBanner) {
if (isMeta) {
editMetaInfoBanner.classList.remove("hidden");
} else {
editMetaInfoBanner.classList.add("hidden");
}
}

// Store server data for modal population
window.Admin.currentEditingServer = server;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Add meta-server fields to servers table

Revision ID: 5126ced48fd0
Revises: 64acf94cb7f2
Create Date: 2026-02-12 10:00:00.000000

"""

# Third-Party
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "5126ced48fd0"
down_revision = "64acf94cb7f2"
branch_labels = None
depends_on = None


def upgrade() -> None:
"""Add server_type, hide_underlying_tools, meta_config, and meta_scope columns to servers."""
inspector = sa.inspect(op.get_bind())

# Skip if table doesn't exist (fresh DB uses db.py models directly)
if "servers" not in inspector.get_table_names():
return

columns = [col["name"] for col in inspector.get_columns("servers")]

if "server_type" not in columns:
op.add_column("servers", sa.Column("server_type", sa.String(20), nullable=False, server_default="standard"))

if "hide_underlying_tools" not in columns:
op.add_column("servers", sa.Column("hide_underlying_tools", sa.Boolean(), nullable=False, server_default=sa.text("true")))

if "meta_config" not in columns:
op.add_column("servers", sa.Column("meta_config", sa.JSON(), nullable=True))

if "meta_scope" not in columns:
op.add_column("servers", sa.Column("meta_scope", sa.JSON(), nullable=True))


def downgrade() -> None:
"""Remove meta-server fields from servers table."""
inspector = sa.inspect(op.get_bind())

if "servers" not in inspector.get_table_names():
return

columns = [col["name"] for col in inspector.get_columns("servers")]

if "meta_scope" in columns:
op.drop_column("servers", "meta_scope")
if "meta_config" in columns:
op.drop_column("servers", "meta_config")
if "hide_underlying_tools" in columns:
op.drop_column("servers", "hide_underlying_tools")
if "server_type" in columns:
op.drop_column("servers", "server_type")
62 changes: 3 additions & 59 deletions mcpgateway/common/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
from functools import lru_cache
from html.parser import HTMLParser
import ipaddress
import json
import logging
from pathlib import Path
import re
Expand All @@ -78,7 +77,9 @@
_HTML_SPECIAL_CHARS_RE: Pattern[str] = re.compile(r'[<>"\']') # / removed per SEP-986
_DANGEROUS_TEMPLATE_TAGS_RE: Pattern[str] = re.compile(r"<(script|iframe|object|embed|link|meta|base|form)\b", re.IGNORECASE)
_EVENT_HANDLER_RE: Pattern[str] = re.compile(r"on\w+\s*=", re.IGNORECASE)
_MIME_TYPE_RE: Pattern[str] = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*(?:\s*;\s*[a-zA-Z0-9!#$&\-\^_+\.]+=(?:[a-zA-Z0-9!#$&\-\^_+\.]+|"[^"\r\n]*"))*$')
_MIME_TYPE_RE: Pattern[str] = re.compile(
r'^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*(?:\s*;\s*[a-zA-Z0-9!#$&\-\^_+\.]+=(?:[a-zA-Z0-9!#$&\-\^_+\.]+|"[^"\r\n]*"))*$'
)
_URI_SCHEME_RE: Pattern[str] = re.compile(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://")
_SHELL_DANGEROUS_CHARS_RE: Pattern[str] = re.compile(r"[;&|`$(){}\[\]<>]")
_ANSI_ESCAPE_RE: Pattern[str] = re.compile(r"\x1B\[[0-9;]*[A-Za-z]")
Expand Down Expand Up @@ -1949,60 +1950,3 @@ def validate_core_url(value: str, field_name: str = "URL") -> str:
The validated URL string.
"""
return SecurityValidator.validate_url(value, field_name)


# CWE-400: Limits for user-supplied meta_data forwarded to upstream MCP servers.
# Keeps arbitrarily large dicts from amplifying into downstream network/DB load.
# These are now read from config (settings.meta_max_keys, etc.) but kept as
# module-level aliases for backward-compatible imports.
META_MAX_KEYS: int = settings.meta_max_keys
META_MAX_DEPTH: int = settings.meta_max_depth
META_MAX_BYTES: int = settings.meta_max_bytes


def validate_meta_data(meta_data: Optional[Dict[str, Any]]) -> None:
"""Enforce size, key-count, and depth limits on user-supplied meta_data (CWE-400).

Args:
meta_data: The metadata dictionary to validate. ``None`` is always accepted.

Raises:
ValueError: if any limit is exceeded.
"""
max_keys = settings.meta_max_keys
max_depth = settings.meta_max_depth
max_bytes = settings.meta_max_bytes

if not meta_data:
return
if len(meta_data) > max_keys:
raise ValueError(f"meta_data exceeds maximum key count ({max_keys}): got {len(meta_data)}")

def _check_depth(obj: Any, depth: int) -> None:
"""Recursively enforce nesting depth, traversing both dicts and lists (CWE-400).

Lists are traversed without incrementing the depth counter so that a
list-of-dicts does not hide an extra level of dict nesting β€” e.g.
``{"k": [{"l2": {"l3": "x"}}]}`` is correctly caught as depth 3.
"""
if depth > max_depth:
raise ValueError(f"meta_data exceeds maximum nesting depth ({max_depth})")
if isinstance(obj, dict):
for v in obj.values():
_check_depth(v, depth + 1)
elif isinstance(obj, list):
for item in obj:
_check_depth(item, depth)

for v in meta_data.values():
_check_depth(v, 1)

try:
# CWE-20: Use strict json.dumps (no default=str) so non-serializable objects
# raise TypeError rather than being silently coerced β€” keeps the byte limit
# meaningful and matches the strict rejection behaviour used in prompt_service.
size = len(json.dumps(meta_data))
if size > max_bytes:
raise ValueError(f"meta_data exceeds maximum size ({max_bytes} bytes): got {size}")
except (TypeError, ValueError) as exc:
raise ValueError(f"meta_data is not serializable: {exc}") from exc
4 changes: 1 addition & 3 deletions mcpgateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,6 @@ class Settings(BaseSettings):
allowed_roots: List[str] = Field(default_factory=list, description="Allowed root paths for resource access")
max_path_depth: int = Field(default=10, description="Maximum allowed path depth")
max_param_length: int = Field(default=10000, description="Maximum parameter length")
meta_max_keys: int = Field(default=16, description="Maximum number of keys in user-supplied meta_data forwarded to upstream MCP servers (CWE-400)")
meta_max_depth: int = Field(default=2, description="Maximum nesting depth for user-supplied meta_data forwarded to upstream MCP servers (CWE-400)")
meta_max_bytes: int = Field(default=4096, description="Maximum JSON-encoded byte size for user-supplied meta_data forwarded to upstream MCP servers (CWE-400)")
dangerous_patterns: List[str] = Field(
default_factory=lambda: [
r"[;&|`$(){}\[\]<>]", # Shell metacharacters
Expand Down Expand Up @@ -1725,6 +1722,7 @@ def parse_issuers(cls, v: Any) -> list[str]:
"Longer responses are truncated to prevent exposing excessive sensitive data. "
"Default: 5000 characters. Range: 1000-100000.",
)
semantic_search_rate_limit: int = 30 # requests per minute for semantic search

# Content Security - Size Limits
content_max_resource_size: int = Field(default=102400, ge=1024, le=10485760, description="Maximum size in bytes for resource content (default: 100KB)") # 100KB # Minimum 1KB # Maximum 10MB
Expand Down
Loading