fix(auth): sign admins and resellers in on a fresh session id, and harden the session cookie

A successful admin or reseller login wrote the signed-in user into whatever
session the visitor arrived with; nothing in the panel ever called
session_regenerate_id. With session.use_strict_mode off, PHP adopts any id a
client presents, so an id planted in an admin's browser beforehand (a cookie
set from a sibling subdomain, a shared machine) became a signed-in admin
session the moment they logged in — session fixation.

Login (admin and reseller) and the first-run setup page now move the session
onto a fresh id and discard the old one. The admin session also starts with
use_strict_mode on, so ids this server never issued are refused, and with the
cookie HttpOnly: no panel script reads it, and an XSS should not be able to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuZvjFSdodqgpyXtaoH1Xt
This commit is contained in:
root
2026-09-12 21:53:20 +00:00
co-authored by Claude Opus 5
parent 1c0494f19e
commit 010cdb1bc9
4 changed files with 135 additions and 0 deletions
+16
View File
@@ -132,6 +132,7 @@ class Authenticator {
$rCrypt = self::hashPassword($rData['password']);
$db->query('UPDATE `users` SET `password` = ?, `last_login` = UNIX_TIMESTAMP(), `ip` = ? WHERE `id` = ?;', $rCrypt, $rIP, $rUserInfo['id']);
self::renewSessionId();
$_SESSION['hash'] = $rUserInfo['id'];
$_SESSION['ip'] = $rIP;
$_SESSION['code'] = AuthRepository::getCurrentCode();
@@ -197,6 +198,7 @@ class Authenticator {
$rCrypt = self::hashPassword($rData['password']);
$db->query('UPDATE `users` SET `password` = ?, `last_login` = UNIX_TIMESTAMP(), `ip` = ? WHERE `id` = ?;', $rCrypt, $rIP, $rUserInfo['id']);
self::renewSessionId();
$_SESSION['reseller'] = $rUserInfo['id'];
$_SESSION['rip'] = $rIP;
$_SESSION['rcode'] = AuthRepository::getCurrentCode();
@@ -218,6 +220,20 @@ class Authenticator {
return array('status' => STATUS_FAILURE);
}
/**
* Move a session that has just signed in onto a fresh id, discarding the old
* one. The id the visitor arrived with is one someone else may know — a cookie
* planted from a sibling subdomain, a shared machine — and keeping it would
* sign them in too (session fixation).
*
* @return void
*/
private static function renewSessionId(): void {
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
}
/**
* Hash a password with a salted, multi-round digest.
*
+1
View File
@@ -77,6 +77,7 @@ if (!RequestManager::has('update')):
$rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');';
if ($db->query($rQuery, ...$rPrepare['data'])) {
session_regenerate_id(true); // signed in from here: a fresh id, as at login
$_SESSION['hash'] = $db->last_insert_id();
$_SESSION['ip'] = NetworkUtils::getUserIP();
$_SESSION['code'] = AuthRepository::getCurrentCode();
+6
View File
@@ -424,7 +424,13 @@ class XC_Bootstrap {
if (session_status() === PHP_SESSION_NONE) {
$rParams = session_get_cookie_params() ?: [];
$rParams['samesite'] = 'Strict';
// The panel's scripts never read the session cookie, so an XSS must
// not be able to either.
$rParams['httponly'] = true;
session_set_cookie_params($rParams);
// Refuse session ids this server never issued, so a visitor cannot
// arrive carrying one an attacker chose.
ini_set('session.use_strict_mode', '1');
session_start();
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace XcVm\Tests\Unit;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use PHPUnit\Framework\TestCase;
use XcVm\Core\Auth\Authenticator;
use XcVm\Core\Database\DatabaseHandler;
use XcVm\Infrastructure\Database\DatabaseFactory;
/** Answers the statements a panel login issues, from fixed rows; never connects. */
class LoginScriptedDb extends DatabaseHandler {
public array $user;
public array $group;
private array $rows = [];
public function __construct() {
$this->dbh = true;
}
public function query($query, $buffered = false) {
$this->rows = [];
if (str_contains($query, 'FROM `users` WHERE `username` = ?')) {
$this->rows = [$this->user];
} elseif (str_contains($query, 'FROM `access_codes` WHERE `code` = ?')) {
$this->rows = [['id' => 1, 'code' => 'panel', 'groups' => json_encode([$this->user['member_group_id']])]];
} elseif (str_contains($query, 'COUNT(*) AS `count` FROM `access_codes`')) {
$this->rows = [['count' => 1]];
} elseif (str_contains($query, 'FROM `users_groups` WHERE `group_id` = ?')) {
$this->rows = [$this->group];
}
return true;
}
public function num_rows() {
return count($this->rows);
}
public function get_row() {
return $this->rows[0] ?? [];
}
}
/**
* A session id the visitor brought to the login form must not be the one that
* carries the signed-in session: whoever planted it (a sibling-subdomain
* cookie, a shared kiosk) would be signed in too.
*
* Each test runs in its own process: PHP refuses to set a session id once
* output has started, and a runner prints between tests.
*/
#[RunTestsInSeparateProcesses]
class LoginSessionFixationTest extends TestCase {
private LoginScriptedDb $db;
protected function setUp(): void {
foreach (['STATUS_FAILURE' => 0, 'STATUS_SUCCESS' => 1, 'STATUS_DISABLED' => 5, 'STATUS_NOT_ADMIN' => 6, 'STATUS_INVALID_CAPTCHA' => 12, 'STATUS_INVALID_CODE' => 13, 'STATUS_NOT_RESELLER' => 35] as $rName => $rValue) {
if (!defined($rName)) {
define($rName, $rValue);
}
}
$_SERVER['XC_CODE'] = 'panel';
$_SERVER['REMOTE_ADDR'] = '192.0.2.10';
$GLOBALS['rSettings'] = ['recaptcha_enable' => 0, 'save_login_logs' => 0];
$this->db = new LoginScriptedDb();
$this->db->user = ['id' => 4, 'username' => 'boss', 'password' => Authenticator::hashPassword('s3cret', 'fixedsalt', 1000), 'member_group_id' => 1, 'status' => 1];
$GLOBALS['db'] = $this->db;
DatabaseFactory::set($this->db);
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
ini_set('session.save_path', sys_get_temp_dir());
session_id('plantedbyattacker0123456789');
@session_start();
}
protected function tearDown(): void {
@session_destroy();
}
public function testAdminLoginIssuesAFreshSessionId(): void {
$this->db->group = ['group_id' => 1, 'is_admin' => 1, 'is_reseller' => 0, 'subresellers' => ''];
$rRes = Authenticator::login(['username' => 'boss', 'password' => 's3cret'], true);
$this->assertSame(STATUS_SUCCESS, $rRes['status']);
$this->assertSame(4, $_SESSION['hash']);
$this->assertTrue(session_id() !== 'plantedbyattacker0123456789', 'the planted session id survived the login');
}
public function testResellerLoginIssuesAFreshSessionId(): void {
$this->db->group = ['group_id' => 1, 'is_admin' => 0, 'is_reseller' => 1, 'subresellers' => ''];
$rRes = Authenticator::resellerLogin(['username' => 'boss', 'password' => 's3cret']);
$this->assertSame(STATUS_SUCCESS, $rRes['status']);
$this->assertSame(4, $_SESSION['reseller']);
$this->assertTrue(session_id() !== 'plantedbyattacker0123456789', 'the planted session id survived the login');
}
/** A failed login changes nothing about the session. */
public function testFailedLoginKeepsTheSession(): void {
$this->db->group = ['group_id' => 1, 'is_admin' => 1, 'is_reseller' => 0, 'subresellers' => ''];
$rRes = Authenticator::login(['username' => 'boss', 'password' => 'wrong'], true);
$this->assertSame(STATUS_FAILURE, $rRes['status']);
$this->assertArrayNotHasKey('hash', $_SESSION);
}
}