Security hardening: token-at-rest, rate limiting, XSS, transactions

- auth: impersonate stores empty data instead of raw cookie; unimpersonate
  issues a fresh session rather than replaying a stored token
- api/index.php: restore rate limiting (10 req/min auth, 120 general)
- nova.js: 401 redirects to login instead of silently returning error;
  escHtml now escapes single quotes to prevent onclick XSS
- accounts: wrap ownership-change 4-write path in beginTransaction/commit;
  restore audit body on account.update
- reseller/user login cards: use $_pname instead of hardcoded 'NovaCPX'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 07:51:21 +00:00
parent d29b8b9d65
commit 89c9bfdc49
6 changed files with 69 additions and 28 deletions
+8 -1
View File
@@ -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) {
+26 -22
View File
@@ -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) {
+31 -1
View File
@@ -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());
}
})();
/**
+2 -2
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── Loading overlay ───────────────────────────────────────────────────────
+1 -1
View File
@@ -56,7 +56,7 @@ $_pname = novacpx_panel_name('NovaCPX');
<linearGradient id="rlgb" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<div style="font-size:1.4rem;font-weight:300">Nova<strong style="font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent">CPX</strong></div>
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
</div>
<div class="card">
+1 -1
View File
@@ -117,7 +117,7 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
<linearGradient id="ulg4" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<div style="font-size:1.4rem;font-weight:300">Nova<strong style="font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent">CPX</strong></div>
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
</div>
<div class="card">