fix(security): require explicit agent shell websocket credentials

This commit is contained in:
Shadowbroker
2026-08-17 01:05:44 -06:00
parent 7afa085d44
commit 4a39c56949
+67 -17
View File
@@ -15,16 +15,12 @@ import struct
import sys
import termios
from typing import Any
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from auth import (
_current_admin_key,
_debug_mode_enabled,
_is_trusted_local_runtime_host,
require_local_operator,
)
from auth import _current_admin_key, require_local_operator
from services.agent_shell_settings import (
get_agent_shell_settings,
set_agent_shell_working_directory,
@@ -44,21 +40,76 @@ def _set_winsize(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _configured_agent_shell_origins() -> set[str]:
"""Return explicitly configured browser origins accepted by the shell."""
allowed: set[str] = set()
for env_name in ("SHADOWBROKER_AGENT_SHELL_ALLOWED_ORIGINS", "CORS_ORIGINS"):
for raw in os.environ.get(env_name, "").split(","):
candidate = raw.strip()
if not candidate:
continue
try:
parsed = urlsplit(candidate)
except ValueError:
continue
if parsed.scheme in {"http", "https"} and parsed.hostname:
allowed.add(f"{parsed.scheme.lower()}://{parsed.netloc.lower()}")
return allowed
def _agent_shell_origin_allowed(ws: WebSocket) -> bool:
"""Reject drive-by browser origins while preserving local/LAN dashboard use."""
origin = str(ws.headers.get("origin", "") or "").strip()
if not origin:
# Non-browser clients do not normally send Origin; they still need an
# explicit one-time token or X-Admin-Key below.
return True
try:
parsed_origin = urlsplit(origin)
except ValueError:
return False
if parsed_origin.scheme not in {"http", "https"} or not parsed_origin.hostname:
return False
normalized_origin = f"{parsed_origin.scheme.lower()}://{parsed_origin.netloc.lower()}"
if normalized_origin in _configured_agent_shell_origins():
return True
# The browser UI connects directly to the backend port on the same host
# (for example localhost:3000 -> localhost:8000). Match the hostname, not
# the port, so normal source, Docker-host, and LAN deployments stay silent.
host_header = str(ws.headers.get("host", "") or "").strip()
if not host_header:
return False
try:
target_host = urlsplit(f"//{host_header}").hostname
except ValueError:
return False
return bool(target_host and parsed_origin.hostname.lower() == target_host.lower())
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 (_debug_mode_enabled() and host == "test"):
return
"""Require an explicit capability; source IP is never authorization."""
if not _agent_shell_origin_allowed(ws):
await ws.close(code=4403, reason="agent shell origin rejected")
raise WebSocketDisconnect()
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()
if admin_key and presented and hmac.compare_digest(presented.encode(), admin_key.encode()):
return
await ws.close(code=4403, reason="local operator access only")
# Browser WebSocket clients cannot set arbitrary authentication headers.
# Keep header auth only for non-browser/native tooling, and never accept a
# long-lived ADMIN_KEY in the URL query string.
if not ws.headers.get("origin"):
admin_key = _current_admin_key()
presented = str(ws.headers.get("x-admin-key", "") or "").strip()
if admin_key and presented and hmac.compare_digest(presented.encode(), admin_key.encode()):
return
await ws.close(code=4403, reason="agent shell credential required")
raise WebSocketDisconnect()
@@ -149,12 +200,11 @@ async def agent_shell_websocket(
cwd: str = Query(default=""),
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, ws_token)
await _authorize_agent_shell_ws(ws, ws_token)
except WebSocketDisconnect:
return