mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -97,6 +97,8 @@ match ($action) {
|
|||||||
[$id]
|
[$id]
|
||||||
);
|
);
|
||||||
if (!$acct) Response::error("Account not found", 404);
|
if (!$acct) Response::error("Account not found", 404);
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
|
||||||
$allowed = ['php_version', 'package_id', 'notes'];
|
$allowed = ['php_version', 'package_id', 'notes'];
|
||||||
$sets = []; $params = [];
|
$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');
|
Response::success(null, 'Account updated');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'suspend' => (function() use ($db, $body, $ownerClause) {
|
'suspend' => (function() use ($db, $body, $ownerClause) {
|
||||||
|
|||||||
@@ -90,9 +90,6 @@ match ($action) {
|
|||||||
Response::error('Access denied', 403);
|
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)
|
// Create a short-lived impersonation session (2h)
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
$sessionId = hash('sha256', $token);
|
$sessionId = hash('sha256', $token);
|
||||||
@@ -106,7 +103,7 @@ match ($action) {
|
|||||||
$_SERVER['REMOTE_ADDR'] ?? '',
|
$_SERVER['REMOTE_ADDR'] ?? '',
|
||||||
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
$callerId,
|
$callerId,
|
||||||
json_encode(['return_token' => $callerRawToken]),
|
json_encode([]),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,30 +132,37 @@ match ($action) {
|
|||||||
);
|
);
|
||||||
if (!$sess || !$sess['impersonator_id']) Response::error('Not impersonating', 400);
|
if (!$sess || !$sess['impersonator_id']) Response::error('Not impersonating', 400);
|
||||||
|
|
||||||
$callerId = (int)$sess['impersonator_id'];
|
$callerId = (int)$sess['impersonator_id'];
|
||||||
$caller = $db->fetchOne("SELECT role FROM users WHERE id = ?", [$callerId]);
|
$caller = $db->fetchOne("SELECT id, role FROM users WHERE id = ?", [$callerId]);
|
||||||
$data = json_decode($sess['data'] ?? '{}', true) ?? [];
|
if (!$caller) Response::error('Caller account not found', 404);
|
||||||
$returnToken = $data['return_token'] ?? '';
|
|
||||||
|
|
||||||
// Delete the impersonation session
|
// Delete the impersonation session
|
||||||
$db->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]);
|
$db->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]);
|
||||||
|
|
||||||
// Restore the caller's original session cookie so they land back in their panel logged in
|
// Issue a fresh session for the caller — no raw token is ever stored at rest
|
||||||
if ($returnToken) {
|
$newToken = bin2hex(random_bytes(32));
|
||||||
setcookie('ncpx_session', $returnToken, [
|
$newSessionId = hash('sha256', $newToken);
|
||||||
'expires' => time() + 28800,
|
$db->execute(
|
||||||
'path' => '/',
|
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at, data)
|
||||||
'secure' => true,
|
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR), ?)",
|
||||||
'httponly' => true,
|
[
|
||||||
'samesite' => 'Strict',
|
$newSessionId,
|
||||||
]);
|
$callerId,
|
||||||
} else {
|
$_SERVER['REMOTE_ADDR'] ?? '',
|
||||||
setcookie('ncpx_session', '', time() - 3600, '/', '', true, true);
|
$_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}");
|
audit('auth.unimpersonate', "returning:{$callerId}");
|
||||||
$role = $caller['role'] ?? 'admin';
|
Response::success(['portal_url' => Auth::portalUrl($caller['role'])], 'Returned to your account');
|
||||||
Response::success(['portal_url' => Auth::portalUrl($role)], 'Returned to your account');
|
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'change-password' => (function() use ($body) {
|
'change-password' => (function() use ($body) {
|
||||||
|
|||||||
+31
-1
@@ -56,7 +56,37 @@ if (!file_exists($endpointFile)) {
|
|||||||
Response::error("Unknown endpoint: $endpoint", 404);
|
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());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ window.Nova = (() => {
|
|||||||
return { success: false, message: 'Network error — check your connection' };
|
return { success: false, message: 'Network error — check your connection' };
|
||||||
}
|
}
|
||||||
_barDone();
|
_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) {
|
if (res.status === 429) {
|
||||||
const reset = res.headers.get('X-RateLimit-Reset');
|
const reset = res.headers.get('X-RateLimit-Reset');
|
||||||
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
||||||
@@ -170,7 +170,7 @@ window.Nova = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(str) {
|
function escHtml(str) {
|
||||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Loading overlay ───────────────────────────────────────────────────────
|
// ── Loading overlay ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</defs>
|
||||||
</svg>
|
</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 style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</defs>
|
||||||
</svg>
|
</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 style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user