chore(release): 1.5.0
Some checks failed
Publish Release / release (push) Failing after 16s

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:
Prophet731
2026-04-28 22:07:27 -04:00
parent 7825f6be80
commit 27cbe40c52
11 changed files with 1873 additions and 363 deletions

View File

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