mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-04 12:02:33 +02:00
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
113 lines
4.0 KiB
PHP
113 lines
4.0 KiB
PHP
<?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);
|
|
}
|
|
}
|