Major client-area overhaul, WHMCS 9 + VirtFusion v7 compatibility, and a
hardening pass on every destructive client.php endpoint.
Tested against WHMCS 9.0.3 + VirtFusion v7.0.0 Build 9.
Features
- "On This Page" jump-link group injected into the WHMCS Actions sidebar
via ClientAreaPrimarySidebar; auto-hides links for hidden panels.
- Monthly traffic chart (last 12 months) with rx/tx bars and centered
legend; replaces the dead canvas that read non-existent JSON paths.
- Live Stats panel: CPU, memory, disk I/O from remoteState; 30s refresh
while the panel is visible AND the page has focus.
- Filesystem usage rows in the Resources panel from qemu-guest-agent
fsinfo; pseudo-FS filtered out.
- Server Overview meta chips: data-center location with country flag,
OS template/agent name with kernel on hover, "Created N days ago".
- Hypervisor maintenance banner at the top of the page.
- Mask Sensitive screenshot mode: IPv4 keeps first two octets, IPv6
keeps first two hextets, hostnames keep first char per dot-label.
Inputs masked via text-security: disc; covers Server Name + Hostname
+ IP cells + rDNS panel rows.
- Per-IP copy buttons folded into the Server Overview cells (replaces
the deleted standalone Network panel).
- VNC viewer popup served from a same-origin authenticated route
(client.php?action=vncViewer) — POST + requireSameOrigin, rotates
the wss token on every open, X-Frame-Options DENY, strict CSP.
Bug Fixes
- UsageUpdate cron silently no-op'd: read server.usage.traffic.used
which doesn't exist. Bandwidth now from /servers/{id}/traffic;
disk usage from remoteState.agent.fsinfo.
- WHMCS 9 multi-service order short-circuit: AfterModuleCreate's
AcceptOrder fired after the first service and terminated the batch
loop, orphaning siblings. Defer until every VF service in the order
has a server_id.
- Orphaned services produced six generic 500s; new
requireProvisionedService() helper emits one clean 409 with an
actionable message. Wired into all 17 client.php cases.
- Server Overview Traffic showed "- / Unlimited"; now renders real
bytes and "Unmetered" (limit=0 is per-period uncapped, not feature-off).
- Rename endpoint moved to PUT /servers/{id}/modify/name in VF v7
(was 404'ing); response is HTTP 201 not 200/204.
- Rename was force-lowercasing the input; relaxed validation to
preserve case + freeze the input row mid-flight to prevent
double-submits.
- "Other" OS category icon override removed; uses VirtFusion's icon
instead of a hardcoded SVG.
- Save button squish on the rename row fixed via flex-wrap layout.
Security
- CSRF protection (requirePost + requireSameOrigin) added to every
destructive POST: rebuild, resetPassword, resetServerPassword,
powerAction, rename, selfServiceAddCredit, toggleVnc, vncViewer.
Previously only rdnsUpdate had it.
- Open-redirect defence in Module::fetchLoginTokens — refuses to
return a redirect URL whose host doesn't match the configured VF
panel hostname.
- Per-action rate limiting via new Module::requireRateLimit helper
(Cache-backed): rebuild 60s, resetPassword/resetServerPassword 30s,
powerAction 10s, vncViewer/toggleVnc/selfServiceAddCredit 5s.
- vncViewer route delivers strict Content-Security-Policy
(default-src none, script-src self + VF panel, connect-src wss VF
panel, frame-ancestors none).
- IPv6 examples in placeholder/comments switched to the IANA
documentation prefix 2001:db8::/32 (RFC 3849).
Removed
- Network panel (duplicated Server Overview IP rows).
- VNC enable/disable toggle (VF firewall flag is non-functional;
toggle was misleading).
- Network Speed row in Resources panel (always 0 from VF API).
Internal
- Module::fetchServerData now passes ?remoteState=true.
- ServerResource::process exposes osName/osPretty/osKernel/osDistro/
osIcon/location/locationIcon/hypervisorMaintenance/createdAt/
builtAt/live.* fields.
- Module::toggleVnc corrected to send {vnc:bool} (the actual API
param) instead of {enabled:bool} (silent no-op).
- Module::getVncConsole + toggleVnc return baseUrl alongside the
envelope so the viewer route can build the wss URL.
- Panel margins tightened mb-3 → mb-2 across all 11 panels.
This commit is contained in:
@@ -52,6 +52,7 @@ require dirname(__DIR__, 3) . '/init.php';
|
||||
* the user sees a generic 500.
|
||||
*/
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
@@ -74,6 +75,13 @@ try {
|
||||
*/
|
||||
case 'resetPassword':
|
||||
|
||||
// Destructive: rotates the customer's VirtFusion login password.
|
||||
// Gated by POST + same-origin (anti-CSRF) and a 30 s rate limit
|
||||
// so a runaway / malicious script can't lock out the customer
|
||||
// by spamming password resets.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
$client = $vf->validateUserOwnsService($serviceID);
|
||||
|
||||
@@ -82,6 +90,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('resetPassword:' . $serviceID, 30);
|
||||
|
||||
$data = $vf->resetUserPassword($serviceID, $client);
|
||||
|
||||
if ($data) {
|
||||
@@ -104,6 +115,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$data = $vf->fetchServerData($serviceID);
|
||||
|
||||
if ($data) {
|
||||
@@ -127,6 +140,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$token = $vf->fetchLoginTokens($serviceID);
|
||||
|
||||
if ($token) {
|
||||
@@ -142,6 +157,12 @@ try {
|
||||
*/
|
||||
case 'powerAction':
|
||||
|
||||
// Destructive: poweroff/restart can interrupt running workloads.
|
||||
// Anti-CSRF + 10 s rate limit (short — power actions can legitimately
|
||||
// cycle quickly when an admin is testing).
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -149,6 +170,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('power:' . $serviceID, 10);
|
||||
|
||||
$powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
|
||||
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
|
||||
|
||||
@@ -172,6 +196,13 @@ try {
|
||||
*/
|
||||
case 'rebuild':
|
||||
|
||||
// Most-destructive client action — wipes the server. Strict
|
||||
// anti-CSRF (a malicious page tricking the customer into
|
||||
// rebuilding their own server destroys data) + 60 s rate limit
|
||||
// (no legitimate flow needs more than one rebuild per minute).
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -179,6 +210,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('rebuild:' . $serviceID, 60);
|
||||
|
||||
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
|
||||
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
|
||||
|
||||
@@ -202,6 +236,10 @@ try {
|
||||
*/
|
||||
case 'rename':
|
||||
|
||||
// Mutation: anti-CSRF. No rate limit — name changes are cheap.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -209,9 +247,14 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
|
||||
|
||||
if (empty($newName) || strlen($newName) > 63 || ! preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) {
|
||||
// VF "name" is a display label, not a DNS hostname — preserve
|
||||
// case + accept any printable string up to 63 chars. The only
|
||||
// hard rejects are empty, oversized, and control characters.
|
||||
if ($newName === '' || strlen($newName) > 63 || preg_match('/[\x00-\x1F\x7F]/', $newName)) {
|
||||
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
@@ -238,6 +281,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$templates = $vf->fetchOsTemplates($serviceID);
|
||||
|
||||
if ($templates !== false) {
|
||||
@@ -257,6 +302,11 @@ try {
|
||||
*/
|
||||
case 'resetServerPassword':
|
||||
|
||||
// Destructive: rotates the VPS root password. Anti-CSRF + 30 s
|
||||
// rate limit so a hostile script can't lock out the customer.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -264,6 +314,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('resetServerPassword:' . $serviceID, 30);
|
||||
|
||||
$result = $vf->resetServerPassword($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -290,6 +343,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getServerBackups($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -316,6 +371,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getTrafficStats($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -342,6 +399,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getVncConsole($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -353,9 +412,36 @@ try {
|
||||
break;
|
||||
|
||||
/**
|
||||
* Toggle VNC on/off.
|
||||
* Render the noVNC viewer HTML page.
|
||||
*
|
||||
* SECURITY MODEL
|
||||
* --------------
|
||||
* This is the popup target instead of a blob URL — it keeps the
|
||||
* wss token out of any URL the customer can copy/share. The page
|
||||
* is gated by the same client.php protections every other action
|
||||
* uses:
|
||||
* - WHMCS session required (isAuthenticated)
|
||||
* - validateUserOwnsService prevents cross-customer access
|
||||
* (any other customer hitting this URL with their session
|
||||
* gets a 403)
|
||||
* - requireProvisionedService blocks orphan services
|
||||
*
|
||||
* Each request rotates the wss token by POSTing to VirtFusion's
|
||||
* /vnc endpoint with vnc:true — older tokens VirtFusion was
|
||||
* tracking are superseded, so a leaked token from a previous
|
||||
* popup open is no longer usable after the next click.
|
||||
*
|
||||
* Method is POST (not GET) so we can require same-origin and
|
||||
* avoid the GET-with-side-effects anti-pattern. JS opens the
|
||||
* popup via a hidden form-submit (see vfOpenVnc in module.js).
|
||||
*
|
||||
* Output is text/html (NOT the JSON the other actions use),
|
||||
* directly delivered to the popup window.
|
||||
*/
|
||||
case 'toggleVnc':
|
||||
case 'vncViewer':
|
||||
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
@@ -364,6 +450,110 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
// 5 s rate limit — protects against runaway-script token rotation
|
||||
// bursts. A legitimate user clicking Open Console twice in a row
|
||||
// (e.g. popup got closed) waits at most 5 s.
|
||||
$vf->requireRateLimit('vncViewer:' . $serviceID, 5);
|
||||
|
||||
// Rotate credentials by toggling vnc=true (idempotent — VF returns
|
||||
// a fresh token + password on each call). Falls back to a plain
|
||||
// GET if the rotate call fails so the customer still gets a
|
||||
// viewer with the existing creds.
|
||||
$vncData = $vf->toggleVnc($serviceID, true);
|
||||
if ($vncData === false) {
|
||||
$vncData = $vf->getVncConsole($serviceID);
|
||||
}
|
||||
|
||||
if ($vncData === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html><head><title>VNC Console</title></head><body style="font-family:sans-serif;padding:40px;text-align:center;color:#aaa;background:#111;">Unable to obtain VNC credentials. The server may be powered off.</body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Drill the response shape the same way module.js used to —
|
||||
// wrapper.data.vnc holds the credentials; wrapper.baseUrl is
|
||||
// added by Module::toggleVnc / Module::getVncConsole.
|
||||
$apiRoot = isset($vncData['data']) ? $vncData['data'] : $vncData;
|
||||
$vnc = $apiRoot['vnc'] ?? [];
|
||||
$baseUrl = $vncData['baseUrl'] ?? '';
|
||||
$wssPath = $vnc['wss']['url'] ?? '';
|
||||
$password = $vnc['password'] ?? '';
|
||||
|
||||
if ($baseUrl === '' || $wssPath === '') {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html><head><title>VNC Console</title></head><body style="font-family:sans-serif;padding:40px;text-align:center;color:#aaa;background:#111;">VNC credentials missing from the API response.</body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Look up the server name for the popup title (best-effort —
|
||||
// doesn't gate rendering).
|
||||
$serverName = '';
|
||||
|
||||
try {
|
||||
$hosting = Capsule::table('tblhosting')->where('id', $serviceID)->first(['domain']);
|
||||
$serverName = $hosting && $hosting->domain ? (string) $hosting->domain : '';
|
||||
} catch (Throwable $e) { /* non-fatal */
|
||||
}
|
||||
|
||||
$vfHost = preg_replace('~^https?://~', '', rtrim($baseUrl, '/'));
|
||||
$vncJsSrc = $baseUrl . '/vnc/vnc.js';
|
||||
|
||||
$esc = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
// Don't let the page be embedded by other origins or cached
|
||||
// intermediaries — the rotated token must not stick around.
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, private');
|
||||
header('Pragma: no-cache');
|
||||
// CSP — only the VirtFusion panel can serve scripts (vnc.js bundle)
|
||||
// and only the wss endpoint on that host accepts our WebSocket.
|
||||
// Self is needed for the inline script that runs the noVNC bundle.
|
||||
header("Content-Security-Policy: default-src 'none'; script-src 'self' " . $baseUrl . '; connect-src wss://' . $vfHost . ' ' . $baseUrl . "; img-src 'self' data: " . $baseUrl . "; style-src 'self' 'unsafe-inline'; frame-ancestors 'none';");
|
||||
?><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>VNC — <?= $esc($serverName) ?></title>
|
||||
<style>html,body{margin:0;padding:0;background:#000;height:100%;font-family:sans-serif;color:#aaa;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<input type="hidden" id="con" value="wss://<?= $esc($vfHost . $wssPath) ?>">
|
||||
<input type="hidden" id="pass" value="<?= $esc($password) ?>">
|
||||
<input type="hidden" id="server-name" value="<?= $esc($serverName) ?>">
|
||||
<div id="noVNC_container" style="position:fixed;inset:0;"></div>
|
||||
<script src="<?= $esc($vncJsSrc) ?>"></script>
|
||||
</body>
|
||||
</html><?php
|
||||
exit;
|
||||
break;
|
||||
|
||||
/**
|
||||
* Toggle VNC on/off.
|
||||
*
|
||||
* Dead path as of 1.5.0 (UI no longer exposes a toggle — see
|
||||
* VNC notes in CLAUDE.md). Kept for backwards-compat in case any
|
||||
* out-of-tree caller invokes it; gated as if it were live so
|
||||
* leaving it here doesn't widen the attack surface.
|
||||
*/
|
||||
case 'toggleVnc':
|
||||
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('toggleVnc:' . $serviceID, 5);
|
||||
|
||||
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
|
||||
$result = $vf->toggleVnc($serviceID, $enabled);
|
||||
|
||||
@@ -391,6 +581,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getSelfServiceUsage($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -413,6 +605,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getSelfServiceReport($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -428,6 +622,13 @@ try {
|
||||
*/
|
||||
case 'selfServiceAddCredit':
|
||||
|
||||
// Money-affecting mutation: anti-CSRF + 5 s rate limit so a
|
||||
// hostile script can't accidentally trigger duplicate charges
|
||||
// by spamming credit-adds. The actual amount is also validated
|
||||
// and money-bound on the WHMCS side, but defence-in-depth.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -435,6 +636,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('selfServiceAddCredit:' . $serviceID, 5);
|
||||
|
||||
$tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0;
|
||||
if ($tokens <= 0) {
|
||||
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
|
||||
@@ -471,6 +675,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
// Reads are permitted for Active + Suspended (a suspended user can still see their rDNS);
|
||||
// Terminated/Pending/Cancelled/Fraud return a clear 400 upfront.
|
||||
$vf->requireServiceStatus($serviceID, ['Active', 'Suspended']);
|
||||
@@ -511,6 +717,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
// Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS.
|
||||
$vf->requireServiceStatus($serviceID, ['Active']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user