diff --git a/panel/api/endpoints/accounts.php b/panel/api/endpoints/accounts.php index 2ab96f3..d206c03 100644 --- a/panel/api/endpoints/accounts.php +++ b/panel/api/endpoints/accounts.php @@ -97,6 +97,8 @@ match ($action) { [$id] ); if (!$acct) Response::error("Account not found", 404); + $db->beginTransaction(); + try { $allowed = ['php_version', 'package_id', 'notes']; $sets = []; $params = []; @@ -203,8 +205,13 @@ match ($action) { } } - audit('account.update', "account:$id"); + $db->commit(); + audit('account.update', "account:$id", $body); Response::success(null, 'Account updated'); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } })(), 'suspend' => (function() use ($db, $body, $ownerClause) { diff --git a/panel/api/endpoints/auth.php b/panel/api/endpoints/auth.php index c276e75..ec76eba 100644 --- a/panel/api/endpoints/auth.php +++ b/panel/api/endpoints/auth.php @@ -90,9 +90,6 @@ match ($action) { Response::error('Access denied', 403); } - // Save caller's current raw session token so we can restore it on unimpersonate - $callerRawToken = $_COOKIE['ncpx_session'] ?? ''; - // Create a short-lived impersonation session (2h) $token = bin2hex(random_bytes(32)); $sessionId = hash('sha256', $token); @@ -106,7 +103,7 @@ match ($action) { $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? '', $callerId, - json_encode(['return_token' => $callerRawToken]), + json_encode([]), ] ); @@ -135,30 +132,37 @@ match ($action) { ); if (!$sess || !$sess['impersonator_id']) Response::error('Not impersonating', 400); - $callerId = (int)$sess['impersonator_id']; - $caller = $db->fetchOne("SELECT role FROM users WHERE id = ?", [$callerId]); - $data = json_decode($sess['data'] ?? '{}', true) ?? []; - $returnToken = $data['return_token'] ?? ''; + $callerId = (int)$sess['impersonator_id']; + $caller = $db->fetchOne("SELECT id, role FROM users WHERE id = ?", [$callerId]); + if (!$caller) Response::error('Caller account not found', 404); // Delete the impersonation session $db->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]); - // Restore the caller's original session cookie so they land back in their panel logged in - if ($returnToken) { - setcookie('ncpx_session', $returnToken, [ - 'expires' => time() + 28800, - 'path' => '/', - 'secure' => true, - 'httponly' => true, - 'samesite' => 'Strict', - ]); - } else { - setcookie('ncpx_session', '', time() - 3600, '/', '', true, true); - } + // Issue a fresh session for the caller — no raw token is ever stored at rest + $newToken = bin2hex(random_bytes(32)); + $newSessionId = hash('sha256', $newToken); + $db->execute( + "INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at, data) + VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR), ?)", + [ + $newSessionId, + $callerId, + $_SERVER['REMOTE_ADDR'] ?? '', + $_SERVER['HTTP_USER_AGENT'] ?? '', + json_encode([]), + ] + ); + setcookie('ncpx_session', $newToken, [ + 'expires' => time() + 28800, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); audit('auth.unimpersonate', "returning:{$callerId}"); - $role = $caller['role'] ?? 'admin'; - Response::success(['portal_url' => Auth::portalUrl($role)], 'Returned to your account'); + Response::success(['portal_url' => Auth::portalUrl($caller['role'])], 'Returned to your account'); })(), 'change-password' => (function() use ($body) { diff --git a/panel/api/index.php b/panel/api/index.php index 2513bed..5528f11 100644 --- a/panel/api/index.php +++ b/panel/api/index.php @@ -56,7 +56,37 @@ if (!file_exists($endpointFile)) { Response::error("Unknown endpoint: $endpoint", 404); } - +// Rate limiting — per-IP, per-endpoint bucket +(function() use ($endpoint) { + $db = DB::getInstance(); + $ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0"; + $now = time(); + $window = 60; + $limit = $endpoint === "auth" ? 10 : 120; + $bucket = $endpoint === "auth" ? "auth" : "api"; + try { + $row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]); + if ($row && ($now - (int)$row["window_start"]) < $window) { + $hits = (int)$row["hits"] + 1; + $db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]); + } else { + $hits = 1; + $db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]); + } + $reset = ($row ? (int)$row["window_start"] : $now) + $window; + $remaining = max(0, $limit - $hits); + header("X-RateLimit-Limit: {$limit}"); + header("X-RateLimit-Remaining: {$remaining}"); + header("X-RateLimit-Reset: {$reset}"); + if ($hits > $limit) { + http_response_code(429); + echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]); + exit; + } + } catch (Throwable $e) { + novacpx_log("warn", "rate limit error: " . $e->getMessage()); + } +})(); /** diff --git a/panel/public/assets/js/nova.js b/panel/public/assets/js/nova.js index f6134d8..97b0fe7 100644 --- a/panel/public/assets/js/nova.js +++ b/panel/public/assets/js/nova.js @@ -56,7 +56,7 @@ window.Nova = (() => { return { success: false, message: 'Network error — check your connection' }; } _barDone(); - if (res.status === 401) { try { return await res.json(); } catch { return { success: false, message: 'Unauthorized' }; } } + if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; } if (res.status === 429) { const reset = res.headers.get('X-RateLimit-Reset'); const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60; @@ -170,7 +170,7 @@ window.Nova = (() => { } function escHtml(str) { - return String(str ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + return String(str ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } // ── Loading overlay ─────────────────────────────────────────────────────── diff --git a/panel/public/reseller/index.php b/panel/public/reseller/index.php index 1761538..a10ba1f 100644 --- a/panel/public/reseller/index.php +++ b/panel/public/reseller/index.php @@ -56,7 +56,7 @@ $_pname = novacpx_panel_name('NovaCPX'); -
NovaCPX
+
Reseller Panel
diff --git a/panel/public/user/index.php b/panel/public/user/index.php index b7b0fbf..a23b010 100644 --- a/panel/public/user/index.php +++ b/panel/public/user/index.php @@ -117,7 +117,7 @@ svg.ring circle { transition: stroke-dashoffset .5s; } -
NovaCPX
+
My Hosting