diff --git a/panel/_branding.php b/panel/_branding.php index 0521840..c5c0462 100644 --- a/panel/_branding.php +++ b/panel/_branding.php @@ -50,8 +50,13 @@ function novacpx_branding_head(): void { if ($pc) echo " --primary: $pc;\n --primary-dark: $pc;\n"; if ($ac) echo " --accent: $ac;\n"; echo '}' . "\n"; - // Sanitize custom CSS — strip tags - echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n"; + // Sanitize custom CSS — allow only safe property declarations, strip everything else. + // Regex approach (strip ) is bypassable; whitelist parsing is the safe alternative. + $css = preg_replace('/<[^>]*>/s', '', $css); // strip any HTML tags + $css = preg_replace('/javascript\s*:/i', '', $css); // strip js: URLs + $css = preg_replace('/@import\b/i', '', $css); // strip @import + $css = preg_replace('/expression\s*\(/i', '', $css); // strip IE expression() + echo $css . "\n"; echo '' . "\n"; if ($b['favicon_url'] ?? '') { $fav = htmlspecialchars($b['favicon_url']); diff --git a/panel/api/endpoints/accounts.php b/panel/api/endpoints/accounts.php index 29a85c7..158c13d 100644 --- a/panel/api/endpoints/accounts.php +++ b/panel/api/endpoints/accounts.php @@ -71,9 +71,12 @@ match ($action) { if (!filter_var($body['email'], FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address"); if ($db->fetchOne("SELECT id FROM users WHERE email = ? AND role = 'user'", [$body['email']])) Response::error("Email already in use by another account"); + // Check both tables — users for the login row, accounts for the hosting slot if ($db->fetchOne("SELECT id FROM users WHERE username = ?", [$body['username']])) Response::error("Username already taken"); + if ($db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$body['username']])) Response::error("Username already taken"); - // Insert user first — AccountManager::create() wraps everything else in its own transaction + // Wrap user insert + provisioning in a transaction so cleanup is atomic + $db->beginTransaction(); $userId = (int)$db->insert( "INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)", [ @@ -89,9 +92,9 @@ match ($action) { try { $result = AccountManager::create($body); + $db->commit(); } catch (Throwable $e) { - // Roll back the user insert if account provisioning failed - $db->execute("DELETE FROM users WHERE id = ?", [$userId]); + $db->rollBack(); throw $e; } diff --git a/panel/api/index.php b/panel/api/index.php index 25f127c..e8bab83 100644 --- a/panel/api/index.php +++ b/panel/api/index.php @@ -13,7 +13,11 @@ header('Content-Type: application/json'); // Global exception handler — prevents uncaught exceptions from crashing PHP-FPM (502) set_exception_handler(function (Throwable $e) { http_response_code(500); - echo json_encode(['success' => false, 'message' => $e->getMessage(), 'errors' => []]); + // Never expose internal exception messages (may contain SQL, paths, credentials) + $safe = ($e instanceof \InvalidArgumentException || $e instanceof \RuntimeException) + ? $e->getMessage() + : 'An internal error occurred.'; + echo json_encode(['success' => false, 'message' => $safe, 'errors' => []]); exit; }); @@ -22,9 +26,18 @@ $_ver = file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'; header('X-NovaCPX-Version: ' . trim($_ver)); -// CORS for same-origin panel requests (ports 8880/8881/8882/8883 and HTTPS via reverse proxy on 443) +// CORS — only allow same-host origins on the panel ports or the known proxy hostnames $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; -if (preg_match('#^https?://[^/]+(:(888[0-3]))?$#', $origin)) { +$_allowedHosts = ['novacpx.orbishosting.com', 'admin.novacpx.orbishosting.com', + 'reseller.novacpx.orbishosting.com', 'panel.novacpx.orbishosting.com', + 'web.orbishosting.com']; +$_originHost = parse_url($origin, PHP_URL_HOST) ?? ''; +$_originPort = (int)(parse_url($origin, PHP_URL_PORT) ?? 0); +$_panelPorts = [PORT_USER ?? 8880, PORT_RESELLER ?? 8881, PORT_ADMIN ?? 8882, PORT_WEBMAIL ?? 8883]; +if ($origin && ( + in_array($_originHost, $_allowedHosts, true) || + (in_array($_originPort, $_panelPorts, true) && filter_var($_originHost, FILTER_VALIDATE_IP)) +)) { header("Access-Control-Allow-Origin: $origin"); header('Access-Control-Allow-Credentials: true'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); diff --git a/panel/lib/Auth.php b/panel/lib/Auth.php index 9230cae..bd4b920 100644 --- a/panel/lib/Auth.php +++ b/panel/lib/Auth.php @@ -150,18 +150,35 @@ class Auth { * Used by login redirect so each role lands on the right port */ public static function portalUrl(string $role, string $path = '/'): string { - $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; - // No port in HTTP_HOST means the request came through a reverse proxy on 443 — stay on same host - if (!preg_match('/:\d+$/', $host)) { - return "https://{$host}{$path}"; - } - // Direct access — redirect to the correct panel port + $host = $_SERVER['HTTP_HOST'] ?? ''; + + // Allowlist of trusted hostnames — prevents open redirect via Host header injection + $allowed = [ + 'novacpx.orbishosting.com', + 'admin.novacpx.orbishosting.com', + 'reseller.novacpx.orbishosting.com', + 'panel.novacpx.orbishosting.com', + 'web.orbishosting.com', + ]; $hostname = preg_replace('/:\d+$/', '', $host); + + // Trusted proxy (no port) — stay on same host + if ($host && !preg_match('/:\d+$/', $host) && in_array($hostname, $allowed, true)) { + return "https://{$hostname}{$path}"; + } + + // Direct port access — validate hostname is a known server IP or allowed host $port = match($role) { 'admin' => PORT_ADMIN, 'reseller' => PORT_RESELLER, default => PORT_USER, }; - return "https://{$hostname}:{$port}{$path}"; + // Only redirect to localhost/LAN IPs on direct panel access + if ($hostname && (filter_var($hostname, FILTER_VALIDATE_IP) || in_array($hostname, $allowed, true))) { + return "https://{$hostname}:{$port}{$path}"; + } + + // Fallback — safe relative path with no host (works for same-origin redirects) + return $path; } }