security: Sprint 00 — critical security fixes (OPC WS auth, Transmission creds, SECRET_KEY, passkey rate limit)
- Add JWT token auth to OPC WebSocket (unauthenticated → 403, per-user tracking) - Externalize Transmission RPC credentials to TRANSMISSION_USER/PASS env vars - Remove hardcoded SECRET_KEY fallback from dev compose - Rate-limit passkey register options endpoint at 5/minute - Add PDD (docs/improvement-plan.md) and sprint specs (specs/) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,13 @@ CC_CONNECT_URL = os.environ.get("CC_CONNECT_URL", "")
|
||||
# OpenClaw
|
||||
OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "")
|
||||
|
||||
# Transmission RPC
|
||||
TRANSMISSION_URL = os.environ.get("TRANSMISSION_URL", "http://host.docker.internal:9091/transmission/rpc")
|
||||
TRANSMISSION_USER = os.environ.get("TRANSMISSION_USER", "")
|
||||
TRANSMISSION_PASS = os.environ.get("TRANSMISSION_PASS", "")
|
||||
if not TRANSMISSION_USER or not TRANSMISSION_PASS:
|
||||
raise RuntimeError("TRANSMISSION_USER and TRANSMISSION_PASS must be set")
|
||||
|
||||
# Networking configs
|
||||
LAN_IP_RANGES = os.environ.get("LAN_IP_RANGES", "192.168.31.0/24").split(",")
|
||||
TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.21.0.1").split(",")
|
||||
|
||||
@@ -6,15 +6,14 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status
|
||||
|
||||
import auth_service as auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Active WebSocket connections
|
||||
active_connections: set[WebSocket] = set()
|
||||
|
||||
|
||||
def serialize_datetime(obj: Any) -> Any:
|
||||
"""Recursively serialize datetime objects to ISO format strings"""
|
||||
@@ -29,21 +28,21 @@ def serialize_datetime(obj: Any) -> Any:
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
"""Manages WebSocket connections with per-user tracking"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: set[WebSocket] = set()
|
||||
self.active_connections: dict[WebSocket, str] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
async def connect(self, websocket: WebSocket, username: str):
|
||||
"""Accept and register a new connection"""
|
||||
await websocket.accept()
|
||||
self.active_connections.add(websocket)
|
||||
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
|
||||
self.active_connections[websocket] = username
|
||||
logger.info(f"WebSocket connected: {username}. Total connections: {len(self.active_connections)}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
"""Remove a connection"""
|
||||
self.active_connections.discard(websocket)
|
||||
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
|
||||
username = self.active_connections.pop(websocket, "unknown")
|
||||
logger.info(f"WebSocket disconnected: {username}. Total connections: {len(self.active_connections)}")
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast message to all connected clients"""
|
||||
@@ -64,9 +63,19 @@ manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint for OPC real-time updates"""
|
||||
await manager.connect(websocket)
|
||||
async def websocket_endpoint(websocket: WebSocket, token: str = Query(None)):
|
||||
"""WebSocket endpoint for OPC real-time updates. Requires JWT token via ?token= query param."""
|
||||
if not token:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing authentication token")
|
||||
return
|
||||
|
||||
try:
|
||||
user = auth.user_from_access_token(token, include_auth_header=False)
|
||||
except HTTPException:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid authentication token")
|
||||
return
|
||||
|
||||
await manager.connect(websocket, user.username)
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -56,7 +56,8 @@ def _get_challenge(client_data_b64: str) -> bytes:
|
||||
|
||||
|
||||
@router.post("/passkey/register/options")
|
||||
async def passkey_register_options(current_user=Depends(auth.get_current_user)):
|
||||
@limiter.limit("5/minute")
|
||||
async def passkey_register_options(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
"""Generate WebAuthn registration options for adding a new passkey."""
|
||||
existing = auth.load_passkey_credentials()
|
||||
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Any, Dict, List
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import config
|
||||
|
||||
router = APIRouter(prefix="/api/transmission", tags=["transmission"])
|
||||
|
||||
|
||||
@@ -12,8 +14,8 @@ def get_session_id() -> str:
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
response = client.get(
|
||||
"http://host.docker.internal:9091/transmission/rpc",
|
||||
auth=("admin", "admin")
|
||||
config.TRANSMISSION_URL,
|
||||
auth=(config.TRANSMISSION_USER, config.TRANSMISSION_PASS)
|
||||
)
|
||||
session_id = response.headers.get("X-Transmission-Session-Id")
|
||||
if not session_id:
|
||||
@@ -35,9 +37,9 @@ def transmission_rpc(method: str, arguments: Dict[str, Any] = None) -> Dict[str,
|
||||
# Use httpx to make the request
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.post(
|
||||
"http://host.docker.internal:9091/transmission/rpc",
|
||||
config.TRANSMISSION_URL,
|
||||
json=payload,
|
||||
auth=("admin", "admin"),
|
||||
auth=(config.TRANSMISSION_USER, config.TRANSMISSION_PASS),
|
||||
headers={"X-Transmission-Session-Id": session_id}
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ os.environ.setdefault("VOLUME_ROOT", "/tmp/test-volume")
|
||||
os.environ.setdefault("DOCKER_HOST", "tcp://mock-docker:2375")
|
||||
os.environ.setdefault("GITEA_URL", "http://mock-gitea:3000")
|
||||
os.environ.setdefault("GITEA_TOKEN", "mock-token")
|
||||
os.environ.setdefault("TRANSMISSION_USER", "test-tx-user")
|
||||
os.environ.setdefault("TRANSMISSION_PASS", "test-tx-pass")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -39,6 +41,8 @@ def mock_config(temp_volume_root, monkeypatch):
|
||||
monkeypatch.setenv("DOCKER_HOST", "tcp://mock-docker:2375")
|
||||
monkeypatch.setenv("GITEA_URL", "http://mock-gitea:3000")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "mock-token")
|
||||
monkeypatch.setenv("TRANSMISSION_USER", "test-tx-user")
|
||||
monkeypatch.setenv("TRANSMISSION_PASS", "test-tx-pass")
|
||||
|
||||
# Reload config module to pick up new env vars
|
||||
import importlib
|
||||
|
||||
Reference in New Issue
Block a user