feat: Phase 2 security audit fixes + script execution platform

Security (P0/P1/P2):
- WebSSH: dedicated short-lived token, server_id binding, 4003 close code
- Remove /api/agent/exec from control plane (RCE surface eliminated)
- Global JWT middleware (JwtAuthMiddleware) with install-mode bypass
- decrypt_value: failure returns None, never leaks ciphertext
- Telegram: sanitize_external_message strips sensitive fields
- Agent auth: startup enforces non-empty API key, compare_digest
- Credentials: password_set bool flag, no plaintext in API responses
- audit_log: writes admin_username + ip_address on all CUD ops
- Install lock: all /api/install/* except GET /status return 403 post-install
- WebSocket dedup: publish once, subscribers deliver locally

Script execution platform:
- script_jobs.py / script_job_callback.py: async batching and callback
- script_execution_store.py / script_callback_rate.py: Redis-backed state
- script_execution_flush.py: background flusher to MySQL
- scripts.html / script-executions.html: full execution UI
- agent_url.py: centralised URL builder

Frontend:
- All 13 pages migrated from CDN to /app/vendor/ (no external deps)
- vendor/: alpinejs, tailwindcss-browser, xterm, xterm-addon-*, qrious
- Dashboard WebSocket real-time alerts; 8h JWT session timeout

Tests:
- test_security_unit.py: 15 unit tests (JWT, sanitize, vendor, install 403)
- test_api.py: env-configurable admin credentials

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 15:26:56 +08:00
parent 341d16fd6d
commit 752a24497c
67 changed files with 4496 additions and 602 deletions
+56 -17
View File
@@ -4,6 +4,7 @@ Presentation layer — receives HTTP requests, delegates to AuthService.
A2: TOTP endpoints now require JWT authentication via get_current_admin.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
@@ -17,6 +18,8 @@ from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger("nexus.auth")
# ── Request Models ──
@@ -38,6 +41,12 @@ class TotpSetupRequest(BaseModel):
admin_id: int
class TotpDisableRequest(BaseModel):
admin_id: int
current_password: str = Field(..., min_length=1, max_length=255)
totp_code: Optional[str] = Field(None, min_length=6, max_length=6)
class TotpVerifyRequest(BaseModel):
admin_id: int
totp_code: str = Field(..., min_length=6, max_length=6)
@@ -48,6 +57,10 @@ class ChangePasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, max_length=255)
class WebsshTokenRequest(BaseModel):
server_id: int = Field(..., ge=1)
# ── Public Routes (no JWT required) ──
@router.post("/login")
@@ -76,11 +89,13 @@ async def login(
@router.post("/refresh")
async def refresh_token(
request: Request,
payload: RefreshRequest,
service: AuthService = Depends(get_auth_service),
):
"""Exchange refresh token for new access + refresh token pair"""
result = await service.refresh_token(payload.refresh_token)
ip_address = request.client.host if request.client else ""
result = await service.refresh_token(payload.refresh_token, ip_address=ip_address)
if not result["success"]:
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
return result
@@ -88,10 +103,12 @@ async def refresh_token(
@router.post("/logout")
async def logout(
request: Request,
payload: LogoutRequest,
service: AuthService = Depends(get_auth_service),
):
"""Invalidate refresh token (client should also discard access token)"""
ip_address = request.client.host if request.client else ""
if payload.refresh_token:
result = await service.logout_by_token(payload.refresh_token)
else:
@@ -143,23 +160,27 @@ async def enable_totp(
@router.post("/totp/disable")
async def disable_totp(
payload: TotpSetupRequest,
payload: TotpDisableRequest,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
):
"""Disable TOTP for admin user (requires JWT auth)
A2: JWT authentication required — admin can only disable TOTP for themselves.
"""
"""Disable TOTP — requires current password + TOTP code when enabled."""
if payload.admin_id != admin.id:
raise HTTPException(status_code=403, detail="Can only disable TOTP for yourself")
result = await service.disable_totp(admin.id)
result = await service.disable_totp(
admin.id,
current_password=payload.current_password,
totp_code=payload.totp_code,
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Disable failed"))
return result
@router.put("/password")
async def change_password(
request: Request,
payload: ChangePasswordRequest,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
@@ -187,13 +208,8 @@ async def change_password(
current_admin.jwt_token_expires = None
await admin_repo.update(current_admin)
# Audit log
request_ip = ""
try:
from fastapi import Request
# IP will be added by the caller context if available
except Exception:
pass
# Audit log with client IP
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -201,12 +217,35 @@ async def change_password(
target_type="admin",
target_id=admin.id,
detail=f"Password changed for {admin.username} (all sessions invalidated)",
ip_address="",
ip_address=ip_address,
))
return {"success": True, "message": "密码已修改,请重新登录"}
@router.post("/webssh-token")
async def issue_webssh_token(
payload: WebsshTokenRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
db: AsyncSession = Depends(get_db),
):
"""Issue a short-lived JWT bound to one server for WebSSH (P0 IDOR fix)."""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
server_repo = ServerRepositoryImpl(db)
server = await server_repo.get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.domain:
raise HTTPException(status_code=400, detail="Server has no domain configured")
ip_address = request.client.host if request.client else ""
result = await service.create_webssh_token(admin, payload.server_id)
return {**result, "server_name": server.name}
@router.get("/me")
async def get_me(
admin: Admin = Depends(get_current_admin),
@@ -219,8 +258,8 @@ async def get_me(
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
repo = SettingRepositoryImpl(db)
system_name = await repo.get("system_name")
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to load system_name in /me: {e}")
return {
"id": admin.id,