mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-19 08:20:42 +03:00
Merge pull request #2817 from yueguobin/fix/mcp-rbac-token-hardening
fix: thread token_version through MCP token minting and guard websocket auth failure
This commit is contained in:
commit
3954d6477a
@ -52,6 +52,10 @@ def has_privilege_on_websocket(
|
|||||||
current_user: schemas.User = Depends(get_current_active_user_from_websocket),
|
current_user: schemas.User = Depends(get_current_active_user_from_websocket),
|
||||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
||||||
):
|
):
|
||||||
|
# Authentication may have failed and closed the socket inside the auth
|
||||||
|
# dependency, returning None — bail out before touching the user object.
|
||||||
|
if current_user is None:
|
||||||
|
return None
|
||||||
if not current_user.is_superadmin:
|
if not current_user.is_superadmin:
|
||||||
path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path
|
path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path
|
||||||
log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
|
log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
|
||||||
|
|||||||
@ -194,6 +194,12 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|||||||
_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||||
"mcp_jwt_username", default=None
|
"mcp_jwt_username", default=None
|
||||||
)
|
)
|
||||||
|
# token_version extracted during token validation — short-lived JWTs minted for
|
||||||
|
# download/console URLs must carry the same version, or the revocation check
|
||||||
|
# (token_data.token_version != user.token_version) rejects them as "revoked".
|
||||||
|
_jwt_token_version_var: contextvars.ContextVar[int] = contextvars.ContextVar(
|
||||||
|
"mcp_jwt_token_version", default=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Token validation ──────────────────────────────────────────────────
|
# ── Token validation ──────────────────────────────────────────────────
|
||||||
@ -208,8 +214,9 @@ async def _resolve_token(token: str) -> str | None:
|
|||||||
"""
|
"""
|
||||||
# Try JWT first
|
# Try JWT first
|
||||||
try:
|
try:
|
||||||
username = auth_service.get_username_from_token(token)
|
token_data = auth_service.get_token_data(token)
|
||||||
_jwt_username_var.set(username)
|
_jwt_username_var.set(token_data.username)
|
||||||
|
_jwt_token_version_var.set(token_data.token_version)
|
||||||
return token
|
return token
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -233,7 +240,8 @@ async def _resolve_token(token: str) -> str | None:
|
|||||||
user = await user_repo.get_user(db_key.user_id)
|
user = await user_repo.get_user(db_key.user_id)
|
||||||
if user:
|
if user:
|
||||||
_jwt_username_var.set(user.username)
|
_jwt_username_var.set(user.username)
|
||||||
fresh_token = auth_service.create_access_token(user.username)
|
_jwt_token_version_var.set(user.token_version)
|
||||||
|
fresh_token = auth_service.create_access_token(user.username, token_version=user.token_version)
|
||||||
return fresh_token
|
return fresh_token
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -292,6 +300,7 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
"server_url": _server_url(),
|
"server_url": _server_url(),
|
||||||
"jwt_token": _jwt_token_var.get(),
|
"jwt_token": _jwt_token_var.get(),
|
||||||
"jwt_username": _jwt_username_var.get(),
|
"jwt_username": _jwt_username_var.get(),
|
||||||
|
"jwt_token_version": _jwt_token_version_var.get(),
|
||||||
}
|
}
|
||||||
result = handler(params, ctx)
|
result = handler(params, ctx)
|
||||||
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
|
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
|
||||||
@ -562,16 +571,18 @@ async def node_console(
|
|||||||
Complete workflow:
|
Complete workflow:
|
||||||
1. Call this tool with project_id and node_id to get the WebSocket URL
|
1. Call this tool with project_id and node_id to get the WebSocket URL
|
||||||
2. Connect to the returned URL using websocat in text mode (-t):
|
2. Connect to the returned URL using websocat in text mode (-t):
|
||||||
> websocat -t "ws://<your-gns3-server-host>:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}"
|
> websocat -t --no-close "ws://<your-gns3-server-host>:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}"
|
||||||
3. Send device commands with \\r\\n line endings via heredoc:
|
3. Send device commands with \\r\\n line endings via heredoc:
|
||||||
> websocat -t "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n'
|
> websocat -t --no-close "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n'
|
||||||
4. Receive response: websocat receives and displays device output
|
4. Receive response: websocat receives and displays device output
|
||||||
Use 'timeout' to avoid connection hanging:
|
Use 'timeout' to avoid connection hanging:
|
||||||
> timeout 10 websocat -t "ws://..." <<< $'commands\\r\\n'
|
> timeout 10 websocat -t --no-close "ws://..." <<< $'commands\\r\\n'
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- Use \\r\\n (not \\n) to match console protocol line endings
|
- Use \\r\\n (not \\n) to match console protocol line endings
|
||||||
- Use $'...' format for escape sequences in bash
|
- Use $'...' format for escape sequences in bash
|
||||||
|
- --no-close keeps the WebSocket open after stdin (heredoc) hits EOF, so
|
||||||
|
device output is not cut off before it arrives
|
||||||
- Set a timeout to prevent hanging connections
|
- Set a timeout to prevent hanging connections
|
||||||
"""
|
"""
|
||||||
return await asyncio.to_thread(_run_handler_sync, get_node_console_info_handler, {
|
return await asyncio.to_thread(_run_handler_sync, get_node_console_info_handler, {
|
||||||
|
|||||||
@ -336,7 +336,7 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
|||||||
if not project_id:
|
if not project_id:
|
||||||
return {"error": "project_id is required"}
|
return {"error": "project_id is required"}
|
||||||
username = gns3_ctx.get("jwt_username")
|
username = gns3_ctx.get("jwt_username")
|
||||||
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
|
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||||
|
|
||||||
link_ids = params.get("link_ids")
|
link_ids = params.get("link_ids")
|
||||||
if link_ids:
|
if link_ids:
|
||||||
|
|||||||
@ -316,7 +316,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
|||||||
console_type = node.get("console_type", "unknown")
|
console_type = node.get("console_type", "unknown")
|
||||||
# Short-lived JWT for the WebSocket URL (10 min)
|
# Short-lived JWT for the WebSocket URL (10 min)
|
||||||
username = gns3_ctx.get("jwt_username")
|
username = gns3_ctx.get("jwt_username")
|
||||||
ws_token = auth_service.create_access_token(username, expires_in=10) if username else None
|
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||||
if ws_token:
|
if ws_token:
|
||||||
raw_url += f"?token={ws_token}"
|
raw_url += f"?token={ws_token}"
|
||||||
@ -328,7 +328,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
|||||||
"node_name": node.get("name"),
|
"node_name": node.get("name"),
|
||||||
"console_type": console_type,
|
"console_type": console_type,
|
||||||
"ws_url": ws_url,
|
"ws_url": ws_url,
|
||||||
"command": f"websocat {ws_url}",
|
"command": f"websocat -t --no-close {ws_url}",
|
||||||
}
|
}
|
||||||
if console_type in ("vnc",):
|
if console_type in ("vnc",):
|
||||||
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
||||||
|
|||||||
@ -54,7 +54,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
|||||||
return {"error": "symbol_id is required"}
|
return {"error": "symbol_id is required"}
|
||||||
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
|
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
|
||||||
username = gns3_ctx.get("jwt_username")
|
username = gns3_ctx.get("jwt_username")
|
||||||
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
|
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||||
result = {
|
result = {
|
||||||
"symbol_id": symbol_id,
|
"symbol_id": symbol_id,
|
||||||
"download_url": download_url,
|
"download_url": download_url,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user