security: agent-shell WS tokens and dependency audit fixes (#409)

Replace spoofable Host/Origin WebSocket auth with short-lived bootstrap
tokens minted over the existing local-operator HTTP path. Docker/browser
shell sessions prefetch a token before connecting; loopback peers remain
unchanged.

Also bump backend ws to 8.21.0 and refresh frontend lockfile to clear
npm audit findings (dev toolchain only for frontend).

Fixes #405, #406, #407

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shadowbroker
2026-06-18 16:40:36 -06:00
committed by GitHub
parent 013849ad1f
commit 91c76ad1bd
8 changed files with 358 additions and 152 deletions
+4 -3
View File
@@ -4,14 +4,15 @@
"requires": true,
"packages": {
"": {
"name": "backend",
"dependencies": {
"ws": "^8.19.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"dependencies": {
"ws": "^8.19.0"
"ws": "^8.21.0"
}
}
+17 -26
View File
@@ -29,6 +29,7 @@ from services.agent_shell_settings import (
get_agent_shell_settings,
set_agent_shell_working_directory,
)
from services.agent_shell_ws_token import consume_agent_shell_ws_token, mint_agent_shell_ws_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["agent-shell"])
@@ -43,32 +44,15 @@ def _set_winsize(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _published_local_dashboard_ws(ws: WebSocket) -> bool:
"""Browser → published Docker port appears as a bridge IP, not loopback.
For the operator shell only, also accept when the upgrade request clearly
targets the local dashboard (Host/Origin on localhost).
"""
host_header = str(ws.headers.get("host") or "").strip().lower()
host_name = host_header.split(":", 1)[0]
if host_name in {"127.0.0.1", "localhost", "::1"}:
return True
origin = str(ws.headers.get("origin") or "").strip().lower()
if origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:"):
return True
if origin.startswith("https://127.0.0.1:") or origin.startswith("https://localhost:"):
return True
return False
async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None:
async def _authorize_agent_shell_ws(
ws: WebSocket,
admin_key_query: str = "",
ws_token_query: str = "",
) -> None:
host = (ws.client.host or "").lower() if ws.client else ""
if (
_is_trusted_local_runtime_host(host)
or _published_local_dashboard_ws(ws)
or (_debug_mode_enabled() and host == "test")
):
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
return
if consume_agent_shell_ws_token(ws_token_query):
return
admin_key = _current_admin_key()
presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip()
@@ -142,6 +126,12 @@ async def read_agent_shell_settings() -> dict[str, Any]:
return get_agent_shell_settings()
@router.post("/api/agent-shell/ws-token", dependencies=[Depends(require_local_operator)])
async def mint_agent_shell_ws_token_route() -> dict[str, Any]:
token, expires_in = mint_agent_shell_ws_token()
return {"token": token, "expires_in": expires_in}
@router.put("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
async def write_agent_shell_settings(body: AgentShellSettingsUpdate) -> dict[str, Any]:
try:
@@ -160,10 +150,11 @@ async def agent_shell_websocket(
cols: int = Query(default=80),
rows: int = Query(default=24),
admin_key: str = Query(default=""),
ws_token: str = Query(default=""),
) -> None:
await ws.accept()
try:
await _authorize_agent_shell_ws(ws, admin_key)
await _authorize_agent_shell_ws(ws, admin_key, ws_token)
except WebSocketDisconnect:
return
+52
View File
@@ -0,0 +1,52 @@
"""Short-lived, single-use WebSocket bootstrap tokens for the agent shell."""
from __future__ import annotations
import secrets
import time
from threading import Lock
_TOKEN_TTL_SECONDS = 60.0
_MAX_ACTIVE_TOKENS = 256
_store: dict[str, float] = {}
_lock = Lock()
def _purge_expired(*, force: bool = False) -> None:
now = time.time()
with _lock:
expired = [token for token, expires in _store.items() if expires <= now]
for token in expired:
_store.pop(token, None)
if force and len(_store) > _MAX_ACTIVE_TOKENS:
for token in list(_store.keys())[: len(_store) - _MAX_ACTIVE_TOKENS]:
_store.pop(token, None)
def mint_agent_shell_ws_token() -> tuple[str, int]:
"""Return (token, expires_in_seconds)."""
_purge_expired()
token = secrets.token_urlsafe(32)
expires_at = time.time() + _TOKEN_TTL_SECONDS
with _lock:
if len(_store) >= _MAX_ACTIVE_TOKENS:
_purge_expired(force=True)
_store[token] = expires_at
return token, int(_TOKEN_TTL_SECONDS)
def consume_agent_shell_ws_token(token: str) -> bool:
"""Validate and burn a one-time token. Returns True when accepted."""
cleaned = str(token or "").strip()
if not cleaned:
return False
now = time.time()
with _lock:
expires_at = _store.pop(cleaned, None)
return expires_at is not None and expires_at > now
def reset_agent_shell_ws_tokens_for_tests() -> None:
with _lock:
_store.clear()
+129
View File
@@ -0,0 +1,129 @@
"""Agent shell WebSocket auth regression tests (issue #407)."""
from __future__ import annotations
import sys
import types
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
if sys.platform == "win32":
fcntl_stub = types.ModuleType("fcntl")
fcntl_stub.ioctl = lambda *args, **kwargs: None
sys.modules.setdefault("fcntl", fcntl_stub)
termios_stub = types.ModuleType("termios")
termios_stub.TIOCSWINSZ = 0
termios_stub.TCSAFLUSH = 0
sys.modules.setdefault("termios", termios_stub)
pty_stub = types.ModuleType("pty")
pty_stub.openpty = lambda: (0, 0)
sys.modules["pty"] = pty_stub
from routers import agent_shell # noqa: E402
from services.agent_shell_ws_token import ( # noqa: E402
consume_agent_shell_ws_token,
mint_agent_shell_ws_token,
reset_agent_shell_ws_tokens_for_tests,
)
@pytest.fixture()
def shell_client():
app = FastAPI()
app.include_router(agent_shell.router)
with TestClient(app) as client:
yield client
@pytest.fixture(autouse=True)
def _reset_ws_tokens():
reset_agent_shell_ws_tokens_for_tests()
yield
reset_agent_shell_ws_tokens_for_tests()
class TestAgentShellWsTokenStore:
def test_mint_and_consume_once(self):
token, expires_in = mint_agent_shell_ws_token()
assert expires_in > 0
assert consume_agent_shell_ws_token(token) is True
assert consume_agent_shell_ws_token(token) is False
class TestAgentShellWsTokenRoute:
def test_loopback_can_mint_token(self, shell_client):
transport = shell_client._transport
transport.client = ("127.0.0.1", 12345)
response = shell_client.post("/api/agent-shell/ws-token")
assert response.status_code == 200
body = response.json()
assert body["token"]
assert body["expires_in"] > 0
def test_remote_caller_cannot_mint_token(self, shell_client):
shell_client._transport.client = ("1.2.3.4", 12345)
with patch("auth._current_admin_key", return_value="test-admin-key-32chars-xxxxxxxxxx"):
response = shell_client.post("/api/agent-shell/ws-token")
assert response.status_code == 403
class TestAgentShellWsAuthorization:
def test_remote_peer_with_spoofed_host_is_denied(self, shell_client):
shell_client._transport.client = ("1.2.3.4", 12345)
with pytest.raises((WebSocketDisconnect, Exception)):
with shell_client.websocket_connect(
"/api/agent-shell/ws",
headers={"host": "localhost:8000"},
) as ws:
ws.receive_text()
def test_remote_peer_with_spoofed_origin_is_denied(self, shell_client):
shell_client._transport.client = ("1.2.3.4", 12345)
with pytest.raises((WebSocketDisconnect, Exception)):
with shell_client.websocket_connect(
"/api/agent-shell/ws",
headers={"origin": "http://localhost:3000"},
) as ws:
ws.receive_text()
def test_remote_peer_with_valid_ws_token_is_accepted(self, shell_client):
shell_client._transport.client = ("127.0.0.1", 12345)
token = shell_client.post("/api/agent-shell/ws-token").json()["token"]
shell_client._transport.client = ("1.2.3.4", 12345)
with patch("sys.platform", "win32"):
with shell_client.websocket_connect(f"/api/agent-shell/ws?ws_token={token}") as ws:
payload = ws.receive_json()
assert payload["type"] == "error"
assert "Windows" in payload["message"]
def test_ws_token_is_single_use(self, shell_client):
shell_client._transport.client = ("127.0.0.1", 12345)
token = shell_client.post("/api/agent-shell/ws-token").json()["token"]
shell_client._transport.client = ("1.2.3.4", 12345)
with patch("sys.platform", "win32"):
with shell_client.websocket_connect(f"/api/agent-shell/ws?ws_token={token}") as ws:
ws.receive_json()
with pytest.raises((WebSocketDisconnect, Exception)):
with shell_client.websocket_connect(f"/api/agent-shell/ws?ws_token={token}") as ws:
ws.receive_text()
def test_loopback_peer_does_not_need_ws_token(self, shell_client):
shell_client._transport.client = ("127.0.0.1", 12345)
with patch("sys.platform", "win32"):
with shell_client.websocket_connect("/api/agent-shell/ws") as ws:
payload = ws.receive_json()
assert payload["type"] == "error"
assert "Windows" in payload["message"]
@pytest.mark.asyncio
async def test_authorize_rejects_spoofed_headers_without_token(self):
ws = MagicMock()
ws.client = MagicMock(host="1.2.3.4")
ws.headers = {"host": "localhost:8000", "origin": "http://localhost:3000"}
ws.close = AsyncMock()
with pytest.raises(WebSocketDisconnect):
await agent_shell._authorize_agent_shell_ws(ws)