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');