fix: replace Bearer JWTs with path-bound access tickets in MCP download tools

link_capture_download and get_symbol embedded a 10-min JWT in the
Authorization header of the curl command they return; LLM clients
retyping that command corrupted the long token — the same failure
class as the console WebSocket URLs fixed in the previous commit.

Generalize the ticket store (console_tickets.py -> access_tickets.py,
ConsoleTicketService -> AccessTicketService): a ticket now binds to
either a node's console endpoints (WebSocket, matched against route
path params) or one exact REST resource path (capture file, symbol
image). The two binding modes are isolated — a node-bound ticket
cannot authenticate a REST resource and vice versa.

get_user_from_token redeems path-bound tickets through the existing
token parameter / Bearer header, matched exactly against
request.url.path, then reuses the shared user lookup and token_version
revocation checks. Download URLs embed ?token=<ticket> and the curl
commands no longer carry a Bearer header.
This commit is contained in:
YueGuobin committed 2026-08-29 01:10:19 +08:00
1 parent 6d6b5351fa
commit d1edfbe5e8
8 files changed
+293 -88

No files matched your search

@@ -30,8 +30,8 @@ from gns3server.db.repositories.api_keys import ApiKeysRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.schemas.controller.tokens import TokenData
from gns3server.services import auth_service, console_ticket_service
from gns3server.services.console_tickets import TICKET_PREFIX
from gns3server.services import auth_service, access_ticket_service
from gns3server.services.access_tickets import TICKET_PREFIX
from .database import get_repository
log = logging.getLogger(__name__)
@@ -50,6 +50,7 @@ def _reject_refresh_token(token_data) -> None:
async def get_user_from_token(
request: Request,
bearer_token: str = Depends(oauth2_scheme),
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
@@ -68,6 +69,38 @@ async def get_user_from_token(
headers={"WWW-Authenticate": "Bearer"},
)
if token.startswith(TICKET_PREFIX):
# Access tickets ("gns3t_…"): short-lived credentials bound to one
# exact resource path, minted by the MCP download tools (capture
# files, symbols). redeem_for_path() confines the ticket to that
# path, so it cannot be replayed against any other resource.
ticket = access_ticket_service.redeem_for_path(token, request.url.path)
if ticket is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired access ticket",
headers={"WWW-Authenticate": "Bearer"},
)
token_data = TokenData(
username=ticket.username,
token_version=ticket.token_version,
token_use="access",
)
user = await user_repo.get_user_by_username(token_data.username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if token_data.token_version != user.token_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token has been revoked for '{token_data.username}'",
headers={"WWW-Authenticate": "Bearer"},
)
return user
# API Key authentication — format: gns3_<api_key_id>_<random_secret>
# Direct lookup by UUID avoids O(n) scan of all keys.
if token.startswith("gns3_"):
@@ -152,12 +185,12 @@ async def get_current_active_user_from_websocket(
try:
if token.startswith(TICKET_PREFIX):
# Console tickets ("gns3t_…"): short-lived credentials bound to one
# node's console endpoints, minted by the node_console MCP tool.
# redeem() confines them to the route matching their binding, so
# they cannot authenticate the notification/wireshark sockets that
# share this dependency.
ticket = console_ticket_service.redeem(token, websocket.path_params)
# Node-bound access tickets ("gns3t_…"): short-lived credentials
# for one node's console endpoints, minted by the node_console
# MCP tool. redeem() confines them to the route matching their
# binding, so they cannot authenticate the notification/wireshark
# sockets that share this dependency.
ticket = access_ticket_service.redeem(token, websocket.path_params)
if ticket is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,