mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-06 12:28:05 +02:00
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:
Generated
+4
-3
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user