From 7d4317667b242fd288ec826d9636bf645ac6b6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=9B=D1=83=D0=BD=D0=B8=D0=BD?= Date: Sat, 30 May 2026 11:54:22 +0300 Subject: [PATCH 1/2] Harden CandyConnect against critical security audit findings. Add input validation, bcrypt client passwords, Redis auth, login rate limiting, and safer protocol provisioning to close shell injection and credential exposure issues identified in the security review. Co-authored-by: Cursor --- .env.example | 37 +++--- Dockerfile | 2 +- docker-compose.yml | 11 +- install.sh | 40 ++++-- server/config.py | 28 ++++- server/database.py | 126 ++++++++++++++----- server/main.py | 28 +++-- server/protocols/base.py | 23 +--- server/protocols/dnstt.py | 23 ++-- server/protocols/ikev2.py | 32 +++-- server/protocols/l2tp.py | 20 ++- server/protocols/openvpn.py | 17 ++- server/protocols/wireguard.py | 12 +- server/routes/client_api.py | 19 +-- server/routes/panel_api.py | 98 ++++++++++----- server/security.py | 210 ++++++++++++++++++++++++++++++++ server/tests/test_security.py | 61 ++++++++++ web-panel/pages/ClientsPage.tsx | 10 +- 18 files changed, 622 insertions(+), 175 deletions(-) create mode 100644 server/security.py create mode 100644 server/tests/test_security.py diff --git a/.env.example b/.env.example index 5ede661..d2b2f97 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,20 @@ -# ═══════════════════════════════════════════════════════════ -# CandyConnect - Environment Configuration -# Copy this file to .env and customize as needed -# ═══════════════════════════════════════════════════════════ - -# Panel port (the main web UI + API port) +CC_DATA_DIR=/opt/candyconnect +CC_REDIS_PASSWORD=change-me-generate-with-openssl-rand +CC_REDIS_URL=redis://:change-me-generate-with-openssl-rand@127.0.0.1:6379/0 +CC_JWT_SECRET= CC_PANEL_PORT=8443 - -# Panel URL path CC_PANEL_PATH=/candyconnect - -# Default admin credentials (CHANGE THESE!) +CC_DOMAIN=vpn.example.com CC_ADMIN_USER=admin -CC_ADMIN_PASS=admin123 - -# JWT secret for token signing (auto-generated if empty) -# Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(48))" -CC_JWT_SECRET= - -# Redis connection URL -# For Docker: redis://redis:6379/0 (uses Docker service name) -# For bare-metal: redis://127.0.0.1:6379/0 -CC_REDIS_URL=redis://redis:6379/0 +# Required on first startup — no default password is accepted by the server. +CC_ADMIN_PASS=change-me-before-first-start +# Optional TLS for uvicorn (prefer reverse proxy in production) +CC_SSL_CERT=/etc/ssl/certs/candyconnect.pem +CC_SSL_KEY=/etc/ssl/private/candyconnect.key +# Comma-separated browser origins allowed to call the API with credentials +CC_CORS_ORIGINS=https://vpn.example.com +CC_JWT_CLIENT_EXPIRE_HOURS=168 +# Set to 1 only when you intentionally want install.sh to wipe Redis +CC_WIPE_REDIS=0 +# Set to 1 to force-reset admin password from CC_ADMIN_PASS on startup +CC_FORCE_ADMIN_SYNC=0 diff --git a/Dockerfile b/Dockerfile index 1e83d36..6dde75a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,7 +89,7 @@ ENV CC_JWT_SECRET="" ENV CC_PANEL_PORT=8443 ENV CC_PANEL_PATH=/candyconnect ENV CC_ADMIN_USER=admin -ENV CC_ADMIN_PASS=admin123 +ENV CC_ADMIN_PASS= EXPOSE 8443 diff --git a/docker-compose.yml b/docker-compose.yml index c6f427f..0ceb816 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: image: redis:7-alpine container_name: candyconnect-redis restart: unless-stopped + command: redis-server --requirepass ${CC_REDIS_PASSWORD:?Set CC_REDIS_PASSWORD in .env} + environment: + - REDIS_PASSWORD=${CC_REDIS_PASSWORD} volumes: - redis_data:/data healthcheck: @@ -46,12 +49,16 @@ services: # - "9443:9443" # TrustTunnel environment: - CC_DATA_DIR=/opt/candyconnect - - CC_REDIS_URL=redis://redis:6379/0 + - CC_REDIS_URL=redis://:${CC_REDIS_PASSWORD}@redis:6379/0 + - CC_REDIS_PASSWORD=${CC_REDIS_PASSWORD} - CC_JWT_SECRET=${CC_JWT_SECRET:-} - CC_PANEL_PORT=${CC_PANEL_PORT:-8443} - CC_PANEL_PATH=${CC_PANEL_PATH:-/candyconnect} - CC_ADMIN_USER=${CC_ADMIN_USER:-admin} - - CC_ADMIN_PASS=${CC_ADMIN_PASS:-admin123} + - CC_ADMIN_PASS=${CC_ADMIN_PASS:?Set CC_ADMIN_PASS in .env before first run} + - CC_CORS_ORIGINS=${CC_CORS_ORIGINS:-} + - CC_SSL_CERT=${CC_SSL_CERT:-} + - CC_SSL_KEY=${CC_SSL_KEY:-} volumes: - candyconnect_data:/opt/candyconnect/cores - candyconnect_backups:/opt/candyconnect/backups diff --git a/install.sh b/install.sh index 10337d1..c4912c4 100644 --- a/install.sh +++ b/install.sh @@ -126,9 +126,9 @@ install_dependencies() { systemctl enable redis-server || true systemctl start redis-server || true - # Wipe database to ensure fresh config (per user request) - if command -v redis-cli &>/dev/null; then - log "Wiping existing Redis database..." + # Wipe database only when explicitly requested (destructive). + if [ "${CC_WIPE_REDIS:-0}" = "1" ] && command -v redis-cli &>/dev/null; then + warn "CC_WIPE_REDIS=1 — wiping Redis database..." redis-cli FLUSHALL >/dev/null 2>&1 || true fi @@ -209,8 +209,9 @@ ask_domain() { generate_secrets() { info "Generating secrets..." JWT_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))") + REDIS_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))") + ADMIN_PASS=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))") - # Write env file (only if it doesn't exist, to preserve user customizations) if [ -f "$CC_DIR/.env" ]; then warn ".env file already exists. Backing up to .env.bak" cp "$CC_DIR/.env" "$CC_DIR/.env.bak" @@ -218,17 +219,39 @@ generate_secrets() { cat > "$CC_DIR/.env" << EOF CC_DATA_DIR=$CC_DIR -CC_REDIS_URL=redis://127.0.0.1:6379/0 +CC_REDIS_URL=redis://:${REDIS_PASSWORD}@127.0.0.1:6379/0 +CC_REDIS_PASSWORD=${REDIS_PASSWORD} CC_JWT_SECRET=$JWT_SECRET CC_PANEL_PORT=$CC_PORT CC_PANEL_PATH=/candyconnect CC_DOMAIN=$CC_DOMAIN CC_ADMIN_USER=admin -CC_ADMIN_PASS=admin123 +CC_ADMIN_PASS=${ADMIN_PASS} EOF chmod 600 "$CC_DIR/.env" log "Secrets generated" + GENERATED_ADMIN_PASS="$ADMIN_PASS" +} + +configure_redis_auth() { + if [ ! -f "$CC_DIR/.env" ]; then + return + fi + # shellcheck disable=SC1090 + source "$CC_DIR/.env" + if [ -z "${CC_REDIS_PASSWORD:-}" ]; then + return + fi + info "Configuring Redis authentication..." + redis-cli CONFIG SET requirepass "$CC_REDIS_PASSWORD" >/dev/null 2>&1 || true + if [ -f /etc/redis/redis.conf ]; then + if ! grep -q "^requirepass " /etc/redis/redis.conf; then + echo "requirepass $CC_REDIS_PASSWORD" >> /etc/redis/redis.conf + fi + fi + systemctl restart redis-server 2>/dev/null || true + log "Redis authentication enabled" } create_systemd_service() { @@ -324,9 +347,9 @@ print_summary() { echo -e " ${BOLD}API URL:${NC} http://${SERVER_IP}:${CC_PORT}/api" echo -e " ${BOLD}Client API:${NC} http://${SERVER_IP}:${CC_PORT}/client-api" echo -e " ${BOLD}Admin User:${NC} admin" - echo -e " ${BOLD}Admin Pass:${NC} admin123" + echo -e " ${BOLD}Admin Pass:${NC} ${GENERATED_ADMIN_PASS:-see $CC_DIR/.env}" echo "" - echo -e " ${YELLOW}⚠ Change the default password immediately!${NC}" + echo -e " ${YELLOW}⚠ Save the admin password now — it is stored only in $CC_DIR/.env${NC}" echo "" echo -e " ${CYAN}Service commands:${NC}" echo -e " systemctl status ${CC_SERVICE}" @@ -350,6 +373,7 @@ main() { install_panel ask_domain generate_secrets + configure_redis_auth setup_firewall create_systemd_service print_summary diff --git a/server/config.py b/server/config.py index 2ddced2..fde8282 100644 --- a/server/config.py +++ b/server/config.py @@ -11,7 +11,25 @@ CORE_DIR = os.path.join(DATA_DIR, "cores") # ── Redis ── -REDIS_URL = os.environ.get("CC_REDIS_URL", "redis://127.0.0.1:6379/0") +def _build_redis_url() -> str: + explicit = os.environ.get("CC_REDIS_URL") + if explicit: + return explicit + password = os.environ.get("CC_REDIS_PASSWORD", "") + if password: + return f"redis://:{password}@127.0.0.1:6379/0" + return "redis://127.0.0.1:6379/0" + + +REDIS_URL = _build_redis_url() + +# ── HTTP / CORS ── +CORS_ORIGINS = [ + origin.strip() + for origin in os.environ.get("CC_CORS_ORIGINS", "").split(",") + if origin.strip() +] +FORCE_ADMIN_SYNC = os.environ.get("CC_FORCE_ADMIN_SYNC", "").lower() in ("1", "true", "yes") # ── JWT (persist secret across restarts unless provided via env) ── @@ -45,7 +63,7 @@ def _load_or_create_jwt_secret() -> str: JWT_SECRET = _load_or_create_jwt_secret() JWT_ALGORITHM = "HS256" JWT_ADMIN_EXPIRE_HOURS = 24 -JWT_CLIENT_EXPIRE_HOURS = 720 # 30 days +JWT_CLIENT_EXPIRE_HOURS = int(os.environ.get("CC_JWT_CLIENT_EXPIRE_HOURS", "168")) # 7 days # ── Panel ── PANEL_PORT = int(os.environ.get("CC_PANEL_PORT", "8443")) @@ -56,7 +74,11 @@ def _load_or_create_jwt_secret() -> str: # ── Default Admin ── DEFAULT_ADMIN_USER = os.environ.get("CC_ADMIN_USER", "admin") -DEFAULT_ADMIN_PASS = os.environ.get("CC_ADMIN_PASS", "admin123") +DEFAULT_ADMIN_PASS = os.environ.get("CC_ADMIN_PASS", "") + +# ── TLS (optional uvicorn termination) ── +SSL_CERTFILE = os.environ.get("CC_SSL_CERT", "") +SSL_KEYFILE = os.environ.get("CC_SSL_KEY", "") # ── Protocols ── SUPPORTED_PROTOCOLS = [ diff --git a/server/database.py b/server/database.py index 4c1893e..e94b181 100644 --- a/server/database.py +++ b/server/database.py @@ -57,6 +57,34 @@ async def close_redis(): K_HEARTBEATS = "cc:heartbeats" # hash: client_id -> last_heartbeat_unix_ts +def _hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def _verify_password(password: str, stored: str) -> bool: + if not stored: + return False + if stored.startswith("$2"): + try: + return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")) + except Exception: + return False + # Legacy plaintext entry — compare once, caller may re-hash. + return stored == password + + +def public_client(client: dict) -> dict: + """Remove sensitive fields from client records returned by APIs.""" + out = dict(client) + out.pop("password", None) + out.pop("password_hash", None) + return out + + +def _gen_l2tp_psk() -> str: + return secrets.token_urlsafe(24) + + def _gen_id() -> str: return "c" + uuid.uuid4().hex[:8] @@ -65,23 +93,32 @@ def _gen_id() -> str: async def init_db(): """Seed default data if DB is empty. Raises on connection failure.""" - # Test Redis connectivity first r = await get_redis() - await r.ping() # Will raise if Redis is unreachable + await r.ping() - # Always sync admin credentials from env vars if they exist - # This allows resetting the password via .env and restart admin_user = DEFAULT_ADMIN_USER - admin_pass = DEFAULT_ADMIN_PASS - - # We use direct bcrypt to avoid passlib issues in docker - password_hash = bcrypt.hashpw(admin_pass.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - await r.hset(K_ADMIN, mapping={ - "username": admin_user, - "password_hash": password_hash, - }) - logging.getLogger("candyconnect").info(f"Admin credentials synced: {admin_user}") + admin_exists = await r.exists(K_ADMIN) + + if not admin_exists: + if not DEFAULT_ADMIN_PASS: + raise RuntimeError( + "CC_ADMIN_PASS must be set before first startup (no default password allowed)" + ) + password_hash = _hash_password(DEFAULT_ADMIN_PASS) + await r.hset(K_ADMIN, mapping={ + "username": admin_user, + "password_hash": password_hash, + }) + logging.getLogger("candyconnect").info(f"Admin account initialized: {admin_user}") + elif FORCE_ADMIN_SYNC and DEFAULT_ADMIN_PASS: + password_hash = _hash_password(DEFAULT_ADMIN_PASS) + await r.hset(K_ADMIN, mapping={ + "username": admin_user, + "password_hash": password_hash, + }) + logging.getLogger("candyconnect").warning( + "Admin credentials reset from environment (CC_FORCE_ADMIN_SYNC=1)" + ) # Panel config if not await r.exists(K_PANEL): @@ -118,17 +155,22 @@ async def init_db(): # Create default 'admin' client if it doesn't exist if not await r.hexists(K_CLIENT_IDX, admin_user): - admin_client_data = { - "username": admin_user, - "password": admin_pass, - "comment": "Default administrator client", - "enabled": True, - "protocols": {p: (p not in ["slipstream", "trusttunnel"]) for p in [ - "v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel" - ]} - } - await create_client(admin_client_data) - logging.getLogger("candyconnect").info(f"Default client '{admin_user}' created") + if not DEFAULT_ADMIN_PASS: + logging.getLogger("candyconnect").warning( + "Skipping default admin VPN client — CC_ADMIN_PASS not set" + ) + else: + admin_client_data = { + "username": admin_user, + "password": DEFAULT_ADMIN_PASS, + "comment": "Default administrator client", + "enabled": True, + "protocols": {p: (p not in ["slipstream", "trusttunnel"]) for p in [ + "v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel" + ]}, + } + await create_client(admin_client_data) + logging.getLogger("candyconnect").info(f"Default client '{admin_user}' created") def _default_core_configs() -> dict: @@ -206,7 +248,7 @@ def _default_core_configs() -> dict: }, "l2tp": { "port": 1701, "ipsec_port": 500, - "psk": "CandyConnect_L2TP_PSK_2026", + "psk": _gen_l2tp_psk(), "local_ip": "10.20.0.1", "remote_range": "10.20.0.10-10.20.0.250", "dns": "1.1.1.1", "mtu": 1400, "mru": 1400, @@ -346,15 +388,15 @@ async def get_client_by_username(username: str) -> Optional[dict]: async def create_client(data: dict) -> dict: r = await get_redis() - # Check duplicate username if await r.hexists(K_CLIENT_IDX, data["username"]): raise ValueError(f"Username '{data['username']}' already exists") + plain_password = data["password"] cid = _gen_id() now = time.strftime("%Y-%m-%d %H:%M:%S") client = { "username": data["username"], - "password": data["password"], + "password_hash": _hash_password(plain_password), "comment": data.get("comment", ""), "enabled": data.get("enabled", True), "group": data.get("group", ""), @@ -378,6 +420,8 @@ async def create_client(data: dict) -> dict: client["id"] = cid client["protocol_traffic"] = {} + # Plain password only for immediate protocol provisioning (never persisted). + client["password"] = plain_password await _add_log("INFO", "System", f"Client '{data['username']}' created") return client @@ -389,10 +433,14 @@ async def update_client(client_id: str, updates: dict) -> Optional[dict]: return None client = json.loads(raw) - for key in ["password", "comment", "enabled", "group", "traffic_limit", "time_limit", "protocols", "protocol_data"]: + for key in ["comment", "enabled", "group", "traffic_limit", "time_limit", "protocols", "protocol_data"]: if key in updates: client[key] = updates[key] + if "password" in updates and updates["password"]: + client["password_hash"] = _hash_password(updates["password"]) + client.pop("password", None) + # Recalculate expiry if time_limit changed if "time_limit" in updates: client["expires_at"] = _calc_expiry(client["created_at"], updates["time_limit"]) @@ -424,10 +472,21 @@ async def verify_client(username: str, password: str) -> Optional[dict]: client = await get_client_by_username(username) if not client: return None - if client.get("password") != password: + stored = client.get("password_hash") or client.get("password", "") + if not _verify_password(password, stored): return None if not client.get("enabled", False): return None + + # Migrate legacy plaintext password to hash on successful login. + if client.get("password") and not client.get("password_hash"): + r = await get_redis() + client["password_hash"] = _hash_password(password) + client.pop("password", None) + await r.hset(K_CLIENTS, client["id"], json.dumps({k: v for k, v in client.items() if k not in ("protocol_traffic", "id")})) + elif client.get("password"): + client.pop("password", None) + return client @@ -522,6 +581,8 @@ async def record_client_connection(client_id: str, ip: str, protocol: str): async def update_client_traffic(client_id: str, protocol: str, bytes_used: float): """Update traffic usage for a client on a specific protocol.""" + if bytes_used < 0: + return r = await get_redis() # Normalize protocol name to avoid mismatches (e.g. V2Ray-1 vs v2ray-1) proto = str(protocol).lower() @@ -595,6 +656,10 @@ async def get_core_config(section: str) -> Optional[dict]: async def update_core_config(section: str, data: dict): + from security import sanitize_wireguard_config + + if section == "wireguard": + data = sanitize_wireguard_config(data) r = await get_redis() await r.hset(K_CONFIGS, section, json.dumps(data)) await _add_log("INFO", "System", f"Configuration updated: {section}") @@ -669,7 +734,6 @@ async def add_tunnel(ip: str, port: int, name: str, username: str = "root", pass "ip": ip, "port": port, "username": username, - "ssh_password": password, "type": tunnel_type, "created_at": int(time.time()), "status": "pending", # pending, installed diff --git a/server/main.py b/server/main.py index 6ca8c5c..405879d 100644 --- a/server/main.py +++ b/server/main.py @@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse -from config import PANEL_PORT, DATA_DIR, CORE_DIR, BACKUP_DIR, LOG_DIR +from config import PANEL_PORT, DATA_DIR, CORE_DIR, BACKUP_DIR, LOG_DIR, CORS_ORIGINS, SSL_CERTFILE, SSL_KEYFILE from database import init_db, close_redis, add_log from protocols.manager import protocol_manager @@ -111,12 +111,13 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +_cors_origins = CORS_ORIGINS or ["http://127.0.0.1:5174", "http://localhost:5174"] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) @@ -181,15 +182,18 @@ async def serve_panel(rest_of_path: str = ""): @app.get("/health") async def health(): - return {"status": "ok", "version": "1.4.2", "timestamp": int(time.time())} + return {"status": "ok", "version": "1.4.2"} if __name__ == "__main__": import uvicorn - uvicorn.run( - "main:app", - host="0.0.0.0", - port=PANEL_PORT, - log_level="info", - reload=False, - ) + uvicorn_kwargs = { + "host": "0.0.0.0", + "port": PANEL_PORT, + "log_level": "info", + "reload": False, + } + if SSL_CERTFILE and SSL_KEYFILE: + uvicorn_kwargs["ssl_certfile"] = SSL_CERTFILE + uvicorn_kwargs["ssl_keyfile"] = SSL_KEYFILE + uvicorn.run("main:app", **uvicorn_kwargs) diff --git a/server/protocols/base.py b/server/protocols/base.py index 138f6b2..78d5367 100644 --- a/server/protocols/base.py +++ b/server/protocols/base.py @@ -123,26 +123,9 @@ async def get_client_config(self, username: str, server_ip: str, protocol_data: async def _run_cmd(self, cmd: str, check: bool = True, timeout: int = 30) -> tuple[int, str, str]: """Run a shell command and return (returncode, stdout, stderr).""" - try: - proc = await asyncio.create_subprocess_shell( - cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - out = stdout.decode("utf-8", errors="replace").strip() - err = stderr.decode("utf-8", errors="replace").strip() - if check and proc.returncode != 0: - logger.error(f"Command failed: {cmd}\nstderr: {err}") - return proc.returncode, out, err - except asyncio.TimeoutError: - logger.error(f"Command timed out after {timeout}s: {cmd}") - try: - proc.kill() - await proc.wait() - except Exception: - pass - return -1, "", f"Command timed out after {timeout}s" + from security import run_cmd + + return await run_cmd(cmd, shell=True, check=check, timeout=timeout) async def _start_process(self, cmd: str, cwd: str = None) -> Optional[int]: """Start a background process and return its PID.""" diff --git a/server/protocols/dnstt.py b/server/protocols/dnstt.py index bb8e31e..c7be005 100644 --- a/server/protocols/dnstt.py +++ b/server/protocols/dnstt.py @@ -203,27 +203,30 @@ async def get_active_connections(self) -> int: return 0 async def add_client(self, username: str, client_data: dict) -> dict: + import shlex + from security import validate_vpn_username, chpasswd_entry + + username = validate_vpn_username(username) ssh_user = f"dnstt_{username}" password = client_data.get("password", self._gen_password()) - # Create system user (non-root, no shell login except through tunnel) await self._run_cmd( - f"sudo useradd -m -s /bin/false {ssh_user} 2>/dev/null || true", + f"sudo useradd -m -s /bin/false {shlex.quote(ssh_user)} 2>/dev/null || true", check=False, ) - # Set password - proc = await asyncio.create_subprocess_shell( - f"echo '{ssh_user}:{password}' | sudo chpasswd", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - await proc.communicate() + rc, _, err = await asyncio.to_thread(chpasswd_entry, ssh_user, password) + if rc != 0: + await add_log("ERROR", self.PROTOCOL_NAME, f"Failed to set DNSTT password: {err}") return {"ssh_username": ssh_user, "ssh_password": password} async def remove_client(self, username: str, protocol_data: dict): + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) ssh_user = protocol_data.get("ssh_username") or f"dnstt_{username}" - await self._run_cmd(f"sudo userdel -r {ssh_user} 2>/dev/null || true", check=False) + await self._run_cmd(f"sudo userdel -r {shlex.quote(ssh_user)} 2>/dev/null || true", check=False) async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict: config = await get_core_config("dnstt") diff --git a/server/protocols/ikev2.py b/server/protocols/ikev2.py index e530352..c5385eb 100644 --- a/server/protocols/ikev2.py +++ b/server/protocols/ikev2.py @@ -194,52 +194,58 @@ async def get_traffic(self) -> dict: async def add_client(self, username: str, client_data: dict) -> dict: """Generate client certificate for IKEv2.""" + import shlex + from security import validate_vpn_username, append_line_to_root_file + + username = validate_vpn_username(username) password = client_data.get("password", username) - # Read cert_validity from config (default 3650 days) config = await get_core_config("ikev2") or {} cert_validity = config.get("cert_validity", 3650) - # Generate client key await self._run_cmd( f"ipsec pki --gen --type rsa --size 2048 --outform pem > {self.IPSEC_DIR}/private/{username}-key.pem", check=False, ) - # Generate client cert await self._run_cmd( f"ipsec pki --pub --in {self.IPSEC_DIR}/private/{username}-key.pem --type rsa | " f"ipsec pki --issue --lifetime {cert_validity} " f"--cacert {self.IPSEC_DIR}/cacerts/ca-cert.pem " f"--cakey {self.IPSEC_DIR}/private/ca-key.pem " - f"--dn 'CN={username}' --san '{username}' " + f"--dn CN={username} --san {username} " f"--outform pem > {self.IPSEC_DIR}/certs/{username}-cert.pem", check=False, ) - # Generate .p12 for client await self._run_cmd( f"openssl pkcs12 -export -in {self.IPSEC_DIR}/certs/{username}-cert.pem " f"-inkey {self.IPSEC_DIR}/private/{username}-key.pem " f"-certfile {self.IPSEC_DIR}/cacerts/ca-cert.pem " - f"-name '{username}' -out {self.IPSEC_DIR}/{username}.p12 " - f"-passout pass:{password}", + f"-name {username} -out {self.IPSEC_DIR}/{username}.p12 " + f"-passout pass:{shlex.quote(password)}", check=False, ) - # Add/Update EAP credentials in ipsec.secrets - await self._run_cmd(f"sudo sed -i '/{username} : EAP/d' /etc/ipsec.secrets 2>/dev/null || true", check=False) await self._run_cmd( - f"echo '{username} : EAP \"{password}\"' | sudo tee -a /etc/ipsec.secrets", + f"sudo sed -i {shlex.quote('/' + username + ' : EAP/d')} /etc/ipsec.secrets 2>/dev/null || true", check=False, ) + await append_line_to_root_file("/etc/ipsec.secrets", f'{username} : EAP "{password}"') return {"cert_generated": True, "username": username} async def remove_client(self, username: str, protocol_data: dict): + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) for ext in ["key.pem", "cert.pem"]: path = os.path.join(self.IPSEC_DIR, "private" if "key" in ext else "certs", f"{username}-{ext}") - await self._run_cmd(f"sudo rm -f {path}", check=False) - await self._run_cmd(f"sudo rm -f {self.IPSEC_DIR}/{username}.p12", check=False) - await self._run_cmd(f"sudo sed -i '/{username}/d' /etc/ipsec.secrets", check=False) + await self._run_cmd(f"sudo rm -f {shlex.quote(path)}", check=False) + await self._run_cmd(f"sudo rm -f {shlex.quote(self.IPSEC_DIR + '/' + username + '.p12')}", check=False) + await self._run_cmd( + f"sudo sed -i {shlex.quote('/' + username + '/d')} /etc/ipsec.secrets", + check=False, + ) async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict: config = await get_core_config("ikev2") diff --git a/server/protocols/l2tp.py b/server/protocols/l2tp.py index f98dd5b..c142e19 100644 --- a/server/protocols/l2tp.py +++ b/server/protocols/l2tp.py @@ -136,16 +136,28 @@ async def get_traffic(self) -> dict: return {"in": total_rx, "out": total_tx} async def add_client(self, username: str, client_data: dict) -> dict: + import shlex + from security import validate_vpn_username, append_line_to_root_file + + username = validate_vpn_username(username) password = client_data.get("password", username) - await self._run_cmd( - f"grep -q '{username}' /etc/ppp/chap-secrets 2>/dev/null || " - f"echo '{username} * {password} *' | sudo tee -a /etc/ppp/chap-secrets", + rc, out, _ = await self._run_cmd( + f"grep -qF {shlex.quote(username)} /etc/ppp/chap-secrets 2>/dev/null", check=False, ) + if rc != 0: + await append_line_to_root_file("/etc/ppp/chap-secrets", f"{username}\t*\t{password}\t*") return {"username": username} async def remove_client(self, username: str, protocol_data: dict): - await self._run_cmd(f"sudo sed -i '/^{username} /d' /etc/ppp/chap-secrets", check=False) + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) + await self._run_cmd( + f"sudo sed -i {shlex.quote('/^' + username + ' /d')} /etc/ppp/chap-secrets", + check=False, + ) async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict: config = await get_core_config("l2tp") diff --git a/server/protocols/openvpn.py b/server/protocols/openvpn.py index ba66de9..843930b 100644 --- a/server/protocols/openvpn.py +++ b/server/protocols/openvpn.py @@ -197,21 +197,30 @@ async def get_traffic(self) -> dict: async def add_client(self, username: str, client_data: dict) -> dict: """Generate client certificate and return .ovpn config data.""" - # Generate client cert + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) + safe_user = shlex.quote(username) await self._run_cmd( - f"cd {self.EASYRSA_DIR} && EASYRSA_BATCH=1 ./easyrsa --batch build-client-full {username} nopass", + f"cd {shlex.quote(self.EASYRSA_DIR)} && EASYRSA_BATCH=1 ./easyrsa --batch build-client-full {safe_user} nopass", check=False, timeout=120, ) return {"cert_generated": True, "username": username} async def remove_client(self, username: str, protocol_data: dict): + import shlex + from security import validate_vpn_username + + username = validate_vpn_username(username) + safe_user = shlex.quote(username) await self._run_cmd( - f"cd {self.EASYRSA_DIR} && EASYRSA_BATCH=1 ./easyrsa --batch revoke {username}", + f"cd {shlex.quote(self.EASYRSA_DIR)} && EASYRSA_BATCH=1 ./easyrsa --batch revoke {safe_user}", check=False, ) await self._run_cmd( - f"cd {self.EASYRSA_DIR} && EASYRSA_BATCH=1 ./easyrsa gen-crl", + f"cd {shlex.quote(self.EASYRSA_DIR)} && EASYRSA_BATCH=1 ./easyrsa gen-crl", check=False, ) diff --git a/server/protocols/wireguard.py b/server/protocols/wireguard.py index 4e38a23..926632f 100644 --- a/server/protocols/wireguard.py +++ b/server/protocols/wireguard.py @@ -282,11 +282,17 @@ async def _write_config(self, config: dict): interface = config.get("bind_interface", "eth0") post_up = config.get("post_up", "") - if not post_up and interface: + if post_up: + from security import assert_safe_wireguard_hook + post_up = assert_safe_wireguard_hook(post_up, "post_up") + elif interface: post_up = f"iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o {interface} -j MASQUERADE" - + post_down = config.get("post_down", "") - if not post_down and interface: + if post_down: + from security import assert_safe_wireguard_hook + post_down = assert_safe_wireguard_hook(post_down, "post_down") + elif interface: post_down = f"iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o {interface} -j MASQUERADE" conf = f"""[Interface] diff --git a/server/routes/client_api.py b/server/routes/client_api.py index ef028ee..9fd936b 100644 --- a/server/routes/client_api.py +++ b/server/routes/client_api.py @@ -4,7 +4,7 @@ """ import time import asyncio -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from typing import Optional @@ -13,6 +13,8 @@ import uuid from system_info import get_public_ip from protocols.manager import protocol_manager +from security import login_rate_limiter, validate_traffic_bytes + from config import SUPPORTED_PROTOCOLS router = APIRouter(tags=["client"]) @@ -24,11 +26,14 @@ class ClientLoginRequest(BaseModel): @router.post("/auth/login") -async def client_login(req: ClientLoginRequest): +async def client_login(req: ClientLoginRequest, request: Request): + login_rate_limiter.check(request, "client") client = await db.verify_client(req.username, req.password) if not client: + login_rate_limiter.record_failure(request, "client") raise HTTPException(status_code=401, detail="Invalid username, password or account disabled") - + + login_rate_limiter.reset(request, "client") token = auth.create_client_token(req.username, client["id"]) # Get panel config for server name and IP if possible @@ -445,10 +450,10 @@ class TrafficReport(BaseModel): @router.post("/traffic") async def report_traffic(req: TrafficReport, payload=Depends(auth.require_client)): client_id = payload.get("client_id") - total_bytes = req.bytes_used or (req.bytes_sent + req.bytes_received) - - # Update client usage in DB (id, protocol, bytes) - await db.update_client_traffic(client_id, req.protocol, total_bytes) + sent = validate_traffic_bytes(req.bytes_sent, "bytes_sent") + received = validate_traffic_bytes(req.bytes_received, "bytes_received") + used = validate_traffic_bytes(req.bytes_used or (sent + received), "bytes_used") + await db.update_client_traffic(client_id, req.protocol, used) return {"success": True, "message": "Traffic reported"} diff --git a/server/routes/panel_api.py b/server/routes/panel_api.py index 81bd332..4c87a44 100644 --- a/server/routes/panel_api.py +++ b/server/routes/panel_api.py @@ -2,9 +2,11 @@ CandyConnect Server - Panel API Router Defines all endpoints for the web management panel. """ +import logging from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Request +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool import paramiko import time @@ -13,8 +15,16 @@ from config import SUPPORTED_PROTOCOLS from system_info import get_server_info from protocols.manager import protocol_manager +from security import ( + login_rate_limiter, + validate_vpn_username, + sanitize_wireguard_config, +) router = APIRouter(tags=["panel"]) +logger = logging.getLogger("candyconnect") + +TUNNEL_INSTALL_URL = "https://get.candyconnect.io/tunnel" # ── Auth ── @@ -23,10 +33,13 @@ class LoginRequest(BaseModel): password: str @router.post("/auth/login") -async def login(req: LoginRequest): +async def login(req: LoginRequest, request: Request): + login_rate_limiter.check(request, "admin") if await db.verify_admin(req.username, req.password): + login_rate_limiter.reset(request, "admin") token = auth.create_admin_token(req.username) return {"success": True, "message": "Login successful", "token": token} + login_rate_limiter.record_failure(request, "admin") raise HTTPException(status_code=401, detail="Invalid username or password") # ── Dashboard ── @@ -73,8 +86,8 @@ async def get_dashboard(limit: int = 10, user=Depends(auth.require_admin)): # ── Clients ── class CreateClientRequest(BaseModel): - username: str - password: str + username: str = Field(min_length=1, max_length=32) + password: str = Field(min_length=8, max_length=128) comment: str = "" enabled: bool = True group: Optional[str] = None @@ -83,7 +96,7 @@ class CreateClientRequest(BaseModel): protocols: dict class UpdateClientRequest(BaseModel): - password: Optional[str] = None + password: Optional[str] = Field(default=None, min_length=8, max_length=128) comment: Optional[str] = None enabled: Optional[bool] = None group: Optional[str] = None @@ -94,31 +107,29 @@ class UpdateClientRequest(BaseModel): @router.get("/clients") async def list_clients(user=Depends(auth.require_admin)): clients = await db.get_all_clients() - return {"success": True, "message": "Clients fetched", "data": clients} + return {"success": True, "message": "Clients fetched", "data": [db.public_client(c) for c in clients]} @router.get("/clients/{id}") async def get_client(id: str, user=Depends(auth.require_admin)): client = await db.get_client(id) if not client: raise HTTPException(status_code=404, detail="Client not found") - return {"success": True, "message": "Client fetched", "data": client} + return {"success": True, "message": "Client fetched", "data": db.public_client(client)} @router.post("/clients") async def create_client(req: CreateClientRequest, user=Depends(auth.require_admin)): try: - # Create in DB - client = await db.create_client(req.model_dump()) - - # Add to protocol backends + payload = req.model_dump() + payload["username"] = validate_vpn_username(payload["username"]) + client = await db.create_client(payload) + plain_password = client.pop("password", None) + client_for_proto = {**client, "password": plain_password or req.password} pdata = await protocol_manager.add_client_to_protocols( - client["username"], client, client["protocols"] + client["username"], client_for_proto, client["protocols"] ) - - # Update DB with protocol-specific data (keys, etc) await db.update_client(client["id"], {"protocol_data": pdata}) client["protocol_data"] = pdata - - return {"success": True, "message": "Client created", "data": client} + return {"success": True, "message": "Client created", "data": db.public_client(client)} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -127,19 +138,20 @@ async def update_client(id: str, req: UpdateClientRequest, user=Depends(auth.req existing = await db.get_client(id) if not existing: raise HTTPException(status_code=404, detail="Client not found") - + try: - updated = await db.update_client(id, req.model_dump(exclude_unset=True)) - - # Update protocol backends + updates = req.model_dump(exclude_unset=True) + updated = await db.update_client(id, updates) + client_for_proto = dict(updated) + if updates.get("password"): + client_for_proto["password"] = updates["password"] pdata = await protocol_manager.add_client_to_protocols( - updated["username"], updated, updated["protocols"], + updated["username"], client_for_proto, updated["protocols"], existing_data=updated.get("protocol_data") ) await db.update_client(id, {"protocol_data": pdata}) updated["protocol_data"] = pdata - - return {"success": True, "message": "Client updated", "data": updated} + return {"success": True, "message": "Client updated", "data": db.public_client(updated)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -173,7 +185,12 @@ class TunnelRequest(BaseModel): @router.get("/tunnels") async def get_tunnels(user=Depends(auth.require_admin)): tunnels = await db.get_tunnels() - return {"success": True, "data": tunnels} + safe = [] + for t in tunnels: + item = dict(t) + item.pop("ssh_password", None) + safe.append(item) + return {"success": True, "data": safe} async def background_install_tunnel(tunnel_id: str, ip: str, port: int, username: str, password: str, cmd: str): await db.update_tunnel_status(tunnel_id, "installing") @@ -182,9 +199,9 @@ async def background_install_tunnel(tunnel_id: str, ip: str, port: int, username def _sync_ssh(): try: client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.load_system_host_keys() + client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect(ip, port=port, username=username, password=password, timeout=15) - # Execute command stdin, stdout, stderr = client.exec_command(cmd) exit_status = stdout.channel.recv_exit_status() out_msg = stdout.read().decode().strip() @@ -194,7 +211,7 @@ def _sync_ssh(): except Exception as e: return False, "", str(e) - success, out, err = await auth.run_in_threadpool(_sync_ssh) + success, out, err = await run_in_threadpool(_sync_ssh) if success: await db.update_tunnel_status(tunnel_id, "installed") @@ -213,16 +230,20 @@ async def add_tunnel(req: TunnelRequest, tasks: BackgroundTasks, user=Depends(au master_ip = server_info.get("ip", "YOUR_SERVER_IP") # Generates a command to run on the remote tunnel server - install_cmd = f"curl -fsSL https://get.candyconnect.io/tunnel | sudo bash -s -- --master {master_ip}:8443 --secret {tunnel['id']} --type {req.tunnel_type}" + install_cmd = ( + f"curl -fsSL {TUNNEL_INSTALL_URL} | sudo bash -s -- " + f"--master {master_ip}:8443 --secret {tunnel['id']} --type {req.tunnel_type}" + ) if req.ip and req.password: tasks.add_task(background_install_tunnel, tunnel['id'], req.ip, req.port, req.username, req.password, install_cmd) return { - "success": True, - "message": "Tunnel added. installation started via SSH" if req.password else "Tunnel added. password missing for SSH install", - "data": tunnel, - "install_command": install_cmd + "success": True, + "message": "Tunnel added. installation started via SSH" if req.password else "Tunnel added. password missing for SSH install", + "data": {k: v for k, v in tunnel.items() if k != "ssh_password"}, + "install_command": install_cmd, + "security_notice": "Verify tunnel installer checksum/source before remote execution.", } @router.delete("/tunnels/{id}") @@ -239,6 +260,8 @@ async def get_cores(user=Depends(auth.require_admin)): @router.post("/cores/{id}/start") async def start_core(id: str, user=Depends(auth.require_admin)): + if id not in SUPPORTED_PROTOCOLS: + raise HTTPException(status_code=400, detail="Unsupported protocol") try: success = await protocol_manager.start_protocol(id) if success: @@ -250,6 +273,8 @@ async def start_core(id: str, user=Depends(auth.require_admin)): @router.post("/cores/{id}/stop") async def stop_core(id: str, user=Depends(auth.require_admin)): + if id not in SUPPORTED_PROTOCOLS: + raise HTTPException(status_code=400, detail="Unsupported protocol") try: success = await protocol_manager.stop_protocol(id) if success: @@ -261,6 +286,8 @@ async def stop_core(id: str, user=Depends(auth.require_admin)): @router.post("/cores/{id}/restart") async def restart_core(id: str, user=Depends(auth.require_admin)): + if id not in SUPPORTED_PROTOCOLS: + raise HTTPException(status_code=400, detail="Unsupported protocol") try: success = await protocol_manager.restart_protocol(id) if success: @@ -279,6 +306,11 @@ async def get_configs(user=Depends(auth.require_admin)): @router.put("/configs/{section}") async def update_config(section: str, data: dict = Body(...), user=Depends(auth.require_admin)): + allowed = set(SUPPORTED_PROTOCOLS) | {"candyconnect"} + if section not in allowed: + raise HTTPException(status_code=400, detail="Invalid configuration section") + if section == "wireguard": + data = sanitize_wireguard_config(data) await db.update_core_config(section, data) return {"success": True, "message": f"Config for {section} updated"} diff --git a/server/security.py b/server/security.py new file mode 100644 index 0000000..2f5ef15 --- /dev/null +++ b/server/security.py @@ -0,0 +1,210 @@ +""" +CandyConnect - Security helpers (validation, safe execution, rate limiting). +""" +from __future__ import annotations + +import asyncio +import re +import shlex +import subprocess +import time +from collections import defaultdict +from typing import Optional + +from fastapi import HTTPException, Request + +# VPN usernames are used in shell-adjacent tooling — strict charset only. +VPN_USERNAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$") + +# Linux interface names (for bind_interface validation). +IFACE_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,15}$") + +# Allowed iptables fragments for WireGuard PostUp/PostDown (no shell metacharacters). +IPTABLES_SAFE_RE = re.compile( + r"^(iptables|ip6tables)(\s+-[a-zA-Z0-9=/_%.]+|\s+%i|\s+--[a-z-]+|\s+\d+|\s+[a-zA-Z0-9_.:/-]+)*" + r"(;\s*(iptables|ip6tables)(\s+-[a-zA-Z0-9=/_%.]+|\s+%i|\s+--[a-z-]+|\s+\d+|\s+[a-zA-Z0-9_.:/-]+)*)*$" +) + +SHELL_METACHAR_RE = re.compile(r"[;|&`$<>\\'\n\r]") + + +def validate_vpn_username(username: str) -> str: + username = (username or "").strip() + if not VPN_USERNAME_RE.match(username): + raise HTTPException( + status_code=400, + detail=( + "Invalid username: use 1-32 chars, start with letter/digit, " + "only letters, digits, underscore and hyphen" + ), + ) + return username + + +def validate_bind_interface(name: str) -> str: + name = (name or "").strip() + if name and not IFACE_RE.match(name): + raise HTTPException(status_code=400, detail="Invalid network interface name") + return name + + +def assert_safe_wireguard_hook(command: str, field_name: str) -> str: + command = (command or "").strip() + if not command: + return "" + if SHELL_METACHAR_RE.search(command): + raise ValueError(f"{field_name} contains disallowed shell characters") + if not IPTABLES_SAFE_RE.match(command): + raise ValueError(f"{field_name} must be iptables/ip6tables rules only") + return command + + +def validate_wireguard_hook(command: str, field_name: str) -> str: + try: + return assert_safe_wireguard_hook(command, field_name) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def sanitize_wireguard_config(data: dict) -> dict: + out = dict(data) + if "bind_interface" in out: + out["bind_interface"] = validate_bind_interface(str(out.get("bind_interface", ""))) + if "post_up" in out: + out["post_up"] = validate_wireguard_hook(str(out.get("post_up", "")), "post_up") + if "post_down" in out: + out["post_down"] = validate_wireguard_hook(str(out.get("post_down", "")), "post_down") + return out + + +def validate_traffic_bytes(value: float, field_name: str = "bytes") -> float: + if value is None: + return 0.0 + try: + num = float(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") + if num < 0: + raise HTTPException(status_code=400, detail=f"{field_name} cannot be negative") + # Cap single report to 1 TiB to prevent abuse / overflow tricks. + if num > 1024 ** 4: + raise HTTPException(status_code=400, detail=f"{field_name} exceeds maximum allowed value") + return num + + +async def run_cmd( + cmd: list[str] | str, + *, + shell: bool = False, + check: bool = True, + timeout: int = 30, + cwd: Optional[str] = None, + input_text: Optional[str] = None, +) -> tuple[int, str, str]: + """Run a subprocess without shell unless explicitly requested.""" + try: + if shell: + proc = await asyncio.create_subprocess_shell( + cmd if isinstance(cmd, str) else " ".join(shlex.quote(c) for c in cmd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + else: + if isinstance(cmd, str): + raise ValueError("Pass argv list when shell=False") + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + stdin=asyncio.subprocess.PIPE if input_text is not None else None, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(input_text.encode("utf-8") if input_text is not None else None), + timeout=timeout, + ) + out = stdout.decode("utf-8", errors="replace").strip() + err = stderr.decode("utf-8", errors="replace").strip() + if check and proc.returncode != 0: + return proc.returncode or 1, out, err + return proc.returncode or 0, out, err + except asyncio.TimeoutError: + return -1, "", f"Command timed out after {timeout}s" + + +def chpasswd_entry(username: str, password: str) -> tuple[int, str, str]: + """Set password via chpasswd stdin (no shell interpolation).""" + base = username[6:] if username.startswith("dnstt_") else username + validate_vpn_username(base) + proc = subprocess.run( + ["sudo", "chpasswd"], + input=f"{username}:{password}\n", + capture_output=True, + text=True, + timeout=15, + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + + +async def append_line_to_root_file(path: str, line: str) -> bool: + """Append a single line to a root-owned file without shell-interpolating content.""" + import os + import tempfile + + tmp_path = "" + try: + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp: + tmp.write(line if line.endswith("\n") else f"{line}\n") + tmp_path = tmp.name + rc, _, _ = await run_cmd( + f"sudo tee -a {shlex.quote(path)} < {shlex.quote(tmp_path)} > /dev/null", + shell=True, + check=False, + ) + return rc == 0 + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + + +class LoginRateLimiter: + """Simple in-memory login rate limiter (per client IP).""" + + def __init__(self, max_attempts: int = 10, window_seconds: int = 300): + self.max_attempts = max_attempts + self.window_seconds = window_seconds + self._attempts: dict[str, list[float]] = defaultdict(list) + + def _client_ip(self, request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "unknown" + + def check(self, request: Request, bucket: str) -> None: + key = f"{bucket}:{self._client_ip(request)}" + now = time.time() + window_start = now - self.window_seconds + self._attempts[key] = [t for t in self._attempts[key] if t >= window_start] + if len(self._attempts[key]) >= self.max_attempts: + raise HTTPException( + status_code=429, + detail="Too many login attempts. Try again later.", + ) + + def record_failure(self, request: Request, bucket: str) -> None: + key = f"{bucket}:{self._client_ip(request)}" + self._attempts[key].append(time.time()) + + def reset(self, request: Request, bucket: str) -> None: + key = f"{bucket}:{self._client_ip(request)}" + self._attempts.pop(key, None) + + +login_rate_limiter = LoginRateLimiter() diff --git a/server/tests/test_security.py b/server/tests/test_security.py new file mode 100644 index 0000000..98c8d3c --- /dev/null +++ b/server/tests/test_security.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Basic security regression checks for CandyConnect server modules.""" +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SERVER = ROOT / "server" +sys.path.insert(0, str(SERVER)) + + +def load(name: str, rel: str): + spec = importlib.util.spec_from_file_location(name, SERVER / rel) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def main() -> int: + security = load("security", "security.py") + + try: + security.validate_vpn_username("valid_user-1") + except Exception as exc: + print(f"FAIL valid username rejected: {exc}") + return 1 + + rejected = False + try: + security.validate_vpn_username("bad;id") + rejected = True + except Exception: + pass + if rejected: + print("FAIL shell username accepted") + return 1 + + try: + security.assert_safe_wireguard_hook( + "iptables -A FORWARD -i %i -j ACCEPT; rm -rf /", + "post_up", + ) + print("FAIL malicious post_up accepted") + return 1 + except ValueError: + pass + + security.validate_traffic_bytes(100) + try: + security.validate_traffic_bytes(-1) + print("FAIL negative traffic accepted") + return 1 + except Exception: + pass + + print("OK security regression checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web-panel/pages/ClientsPage.tsx b/web-panel/pages/ClientsPage.tsx index b1dbc65..b71b547 100644 --- a/web-panel/pages/ClientsPage.tsx +++ b/web-panel/pages/ClientsPage.tsx @@ -82,7 +82,7 @@ const ClientsPage: React.FC = () => { const openEditModal = (client: Client) => { setIsAddMode(false); setEditClient(client); - setFUsername(client.username); setFPassword(client.password); setFComment(client.comment); + setFUsername(client.username); setFPassword(''); setFComment(client.comment); setFTrafficVal(client.traffic_limit.value); setFTrafficUnit(client.traffic_limit.unit as any); setFTimeVal(client.time_limit.value); setFTimeMode(client.time_limit.mode as any); setFOnHold(client.time_limit.on_hold); setFEnabled(client.enabled); setFGroup(client.group || ''); @@ -90,7 +90,8 @@ const ClientsPage: React.FC = () => { }; const handleSave = async () => { - if (!fUsername.trim() || !fPassword.trim()) { notify('Username and password required', 'error'); return; } + if (!fUsername.trim()) { notify('Username required', 'error'); return; } + if (!editClient && !fPassword.trim()) { notify('Password required for new clients', 'error'); return; } if (fTrafficVal <= 0 || fTimeVal <= 0) { notify('Traffic and time must be positive', 'error'); return; } setSaving(true); try { @@ -105,7 +106,8 @@ const ClientsPage: React.FC = () => { notify(`Client "${fUsername}" created`, 'success'); } else if (editClient) { await updateClient(editClient.id, { - password: fPassword.trim(), comment: fComment.trim(), enabled: fEnabled, + ...(fPassword.trim() ? { password: fPassword.trim() } : {}), + comment: fComment.trim(), enabled: fEnabled, group: fGroup.trim() || undefined, traffic_limit: { value: fTrafficVal, unit: fTrafficUnit }, time_limit: { mode: fTimeMode, value: fTimeVal, on_hold: fOnHold }, @@ -210,7 +212,7 @@ const ClientsPage: React.FC = () => { {detailClient && (
{[ - ['Username', detailClient.username], ['Password', detailClient.password], + ['Username', detailClient.username], ['Password', '•••••••• (not stored in panel)'], ['Status', detailClient.is_online ? Online : !detailClient.enabled ? Disabled : Offline], ['Group', detailClient.group || 'None'], ['Comment', detailClient.comment || '-'], From 45bc42619d5fa834e8d8cdcb273ffc7a8c1fe3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=9B=D1=83=D0=BD=D0=B8=D0=BD?= Date: Sat, 30 May 2026 12:36:07 +0300 Subject: [PATCH 2/2] Fix vpnlab deployment issues from security hardening branch. Import FORCE_ADMIN_SYNC in database.py so admin reset works, surface FastAPI error details in the panel API client, and hoist PanelConfigs Card to stop password inputs losing focus on re-render. Co-authored-by: Cursor --- server/database.py | 2 +- web-panel/pages/PanelConfigsPage.tsx | 8 ++++---- web-panel/services/api.ts | 10 +++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/server/database.py b/server/database.py index e94b181..0fe3ecf 100644 --- a/server/database.py +++ b/server/database.py @@ -8,7 +8,7 @@ import redis.asyncio as redis from config import ( - REDIS_URL, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASS, + REDIS_URL, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASS, FORCE_ADMIN_SYNC, PANEL_PORT, PANEL_PATH, PANEL_DOMAIN, PANEL_VERSION, PANEL_BUILD_DATE, ) diff --git a/web-panel/pages/PanelConfigsPage.tsx b/web-panel/pages/PanelConfigsPage.tsx index 07887e1..ec90279 100644 --- a/web-panel/pages/PanelConfigsPage.tsx +++ b/web-panel/pages/PanelConfigsPage.tsx @@ -3,6 +3,10 @@ import { getPanel, updatePanel, changePassword as apiChangePassword, restartPane import { useNotify } from '../components/Notification'; import { Wrench, Globe, AlertTriangle, Key, Lock, Info, Flame, Loader2 } from 'lucide-react'; +const Card: React.FC<{ children: React.ReactNode; danger?: boolean }> = ({ children, danger }) => ( +
{children}
+); + const PanelConfigsPage: React.FC = () => { const { notify } = useNotify(); const [data, setData] = useState(null); @@ -88,10 +92,6 @@ const PanelConfigsPage: React.FC = () => { const cfg = data.config; const s = data.server; - const Card: React.FC<{ children: React.ReactNode; danger?: boolean }> = ({ children, danger }) => ( -
{children}
- ); - return (
diff --git a/web-panel/services/api.ts b/web-panel/services/api.ts index 8291c7d..16b3128 100644 --- a/web-panel/services/api.ts +++ b/web-panel/services/api.ts @@ -32,7 +32,15 @@ async function request( window.location.reload(); } - return res.json(); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = (data as { detail?: unknown; message?: string }).detail; + const message = typeof detail === "string" + ? detail + : (data as { message?: string }).message || `Request failed (${res.status})`; + throw new Error(message); + } + return data as ApiResponse; } // ── Auth ──