Files
opensourcepos/tests/Controllers/HomeTest.php
objecttothis 905a447e55 fix(reports, home): resolve double-URL-decoding bypass for method grants (#4660) (#4666)
fix(reports, home): strengthen method grant validation and URI decoding (#4660)

- Reports, Home: fix URI decoding in hasGrant() method checks to use
  urldecode consistently, preventing malformed URI segments from
  bypassing access controls
- Reports: rename snake_case variables to camelCase for PSR-12 compliance
- Reports: adjust access checks to accurately handle null submodule IDs

Tests:
- Add grant check tests for encoded URI inputs across Reports and Home
- Add test case for employee access with base reports grant
- Add secondary grant check for reports_customers in relevant test cases
- Confirm logout bypass remains functional and properly controlled
- Refactor TestDatabaseBootstrapSeeder to expose static reset() for
  per-class DB re-initialization instead of only via seeder run()
- Standardize session handling, setup logic, and boolean declarations
- Use unique data in test helpers to avoid collisions
- Add docblocks to ReportsControllerTest and HomeTest for PSR-5 compliance
- Add exception handling for failed employee creation in test setup
- Wrap password validation test in try-finally to guarantee state cleanup

Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-08-31 13:33:09 +04:00

551 lines
18 KiB
PHP

<?php
namespace Tests\Controllers;
use CodeIgniter\Database\Config;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use App\Models\Employee;
use RuntimeException;
/**
* Test suite for Home controller password validation
*
* Tests the critical fix for password minimum length validation bypass
* Issue: Code was checking hashed password length (always 60 chars) instead of actual password
* Fix: Validate raw password length BEFORE hashing
*/
class HomeTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = false;
protected $namespace = null;
private static bool $doneBootstrap = false;
protected function setUp(): void
{
if (self::$doneBootstrap === false) {
Config::seeder($this->DBGroup)->call('App\Database\Seeds\TestDatabaseBootstrapSeeder');
Config::connect($this->DBGroup)->close();
self::$doneBootstrap = true;
}
parent::setUp();
}
/**
* Test password validation rejects passwords shorter than 8 characters
*
* @return void
*/
public function testPasswordMinLength_Rejects7Characters(): void
{
$this->resetSession();
// Attempt to change password to 7 characters
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => '1234567' // 7 characters
]);
// Assert failure response
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success'], 'Password with 7 chars should be rejected');
$this->assertEquals(-1, $result['id']);
// Verify password was not changed
$employee = model(Employee::class);
$admin = $employee->get_info(1);
$this->assertTrue(password_verify('pointofsale', $admin->password),
'Password should not have been changed');
}
/**
* Test password validation accepts passwords with exactly 8 characters
*
* @return void
*/
public function testPasswordMinLength_Accepts8Characters(): void
{
$this->resetSession();
// Change password to exactly 8 characters
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => 'pa$$w0rd' // Exactly 8 characters including special chars
]);
// Assert success response
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success'], 'Password with 8 chars should be accepted');
$this->assertEquals(1, $result['id']);
// Verify password was changed
$employee = model(Employee::class);
$admin = $employee->get_info(1);
$this->assertTrue(password_verify('pa$$w0rd', $admin->password),
'Password with 8 chars should be accepted');
// Restore original password
$employee->change_password([
'username' => 'admin',
'password' => password_hash('pointofsale', PASSWORD_DEFAULT),
'hash_version' => 2
], 1);
}
/**
* Test password validation rejects empty password
*
* @return void
*/
public function testPasswordMinLength_RejectsEmptyString(): void
{
$this->resetSession();
// Attempt to set empty password
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => '' // Empty string
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success'], 'Empty password should be rejected');
$this->assertEquals(-1, $result['id']);
}
/**
* Password validation is a raw strlen() check with no whitespace
* handling, so a password consisting entirely of spaces still counts
* toward the length minimum.
*
* @return void
*/
public function testPasswordMinLength_WhitespaceOnlyPasswordCountsTowardLength(): void
{
$this->resetSession();
try {
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => ' ' // 8 spaces: exactly meets the byte-length minimum
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success'], 'strlen()-based validation accepts 8 spaces as meeting the minimum length');
} finally {
// Restore original password
$employee = model(Employee::class);
$employee->change_password([
'username' => 'admin',
'password' => password_hash('pointofsale', PASSWORD_DEFAULT),
'hash_version' => 2
], 1);
}
}
/**
* Test password validation accepts passwords with special characters
* as long as they meet minimum length
*
* @return void
*/
public function testPasswordMinLength_AcceptsSpecialCharacters(): void
{
$this->resetSession();
$specialPassword = 'Str0ng!@#$'; // 11 characters with special chars
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => $specialPassword
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success'], 'Password with special chars should be accepted');
$this->assertEquals(1, $result['id']);
// Verify password works
$employee = model(Employee::class);
$admin = $employee->get_info(1);
$this->assertTrue(password_verify($specialPassword, $admin->password));
// Restore original password
$employee->change_password([
'username' => 'admin',
'password' => password_hash('pointofsale', PASSWORD_DEFAULT),
'hash_version' => 2
], 1);
}
/**
* Regression test: Verify previous vulnerable behavior is fixed
*
* Before fix: 1-character passwords like "a" were accepted because
* code checked len(hashed_password) which is always 60 for bcrypt
* After fix: Raw password is validated before hashing
*
* @return void
*/
public function testPasswordMinLength_RejectsPreviousBehavior(): void
{
$this->resetSession();
// Attempt the previously vulnerable case: single character password
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => 'a' // Previously allowed due to bug
]);
// This should now fail
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success'], 'Single character password should be rejected (CVE fix)');
$this->assertEquals(-1, $result['id']);
// Verify password was NOT changed
$employee = model(Employee::class);
$admin = $employee->get_info(1);
$this->assertTrue(password_verify('pointofsale', $admin->password),
'Single character password should be rejected (CVE fix)');
}
/**
* Helper method to reset session
*
* @return void
*/
protected function resetSession(): void
{
$this->withSession(['person_id' => 1]); // Admin user
}
/**
* Create a non-admin employee for testing
*
* @param array $overrides Optional overrides for username, email, password, etc.
* @return int The person_id of the created employee
*/
protected function createNonAdminEmployee(array $overrides = []): int
{
$uniqueSuffix = uniqid();
$personData = [
'first_name' => $overrides['first_name'] ?? 'NonAdmin',
'last_name' => $overrides['last_name'] ?? 'User',
'email' => $overrides['email'] ?? "nonadmin{$uniqueSuffix}@test.com",
'phone_number' => $overrides['phone_number'] ?? '555-1234'
];
$employeeData = [
'username' => $overrides['username'] ?? "nonadmin{$uniqueSuffix}",
'password' => password_hash($overrides['password'] ?? 'password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = $overrides['grants'] ?? [
['permission_id' => 'home', 'menu_group' => 'home'],
['permission_id' => 'customers', 'menu_group' => 'home'],
['permission_id' => 'sales', 'menu_group' => 'home']
];
$employeeModel = model(Employee::class);
$saved = $employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
if (!$saved || empty($personData['person_id'])) {
throw new RuntimeException('Failed to create non-admin employee for testing');
}
return (int) $personData['person_id'];
}
/**
* Login as a specific user
*
* @param int $personId
* @return void
*/
protected function loginAs(int $personId): void
{
$this->withSession([
'person_id' => $personId,
'menu_group' => 'home',
]);
}
// ========== BOLA Authorization Tests ==========
/**
* Test non-admin cannot view admin password change form
* BOLA vulnerability fix: GHSA-q58g-gg7v-f9rf
*
* @return void
*/
public function testNonAdminCannotViewAdminPasswordForm(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->get('/home/changePassword/1');
$response->assertStatus(403);
}
/**
* Test non-admin cannot change admin password
* BOLA vulnerability fix: GHSA-q58g-gg7v-f9rf
*
* @return void
*/
public function testNonAdminCannotChangeAdminPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->post('/home/save/1', [
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => 'hacked123'
]);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
// Verify admin password was NOT changed
$employee = model(Employee::class);
$admin = $employee->get_info(1);
$this->assertTrue(password_verify('pointofsale', $admin->password),
'Admin password should not have been changed by non-admin');
}
/**
* Test user can view their own password change form
*
* @return void
*/
public function testUserCanViewOwnPasswordForm(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->get('/home/changePassword/' . $nonAdminId);
$response->assertStatus(200);
$response->assertSee('nonadmin'); // Username should be visible
}
/**
* Test user can change their own password
*
* @return void
*/
public function testUserCanChangeOwnPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee(['username' => 'nonadminchangeown']);
$this->loginAs($nonAdminId);
$response = $this->post('/home/save/' . $nonAdminId, [
'username' => 'nonadminchangeown',
'current_password' => 'password123',
'password' => 'newpassword123'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
// Verify password was changed
$employee = model(Employee::class);
$user = $employee->get_info($nonAdminId);
$this->assertTrue(password_verify('newpassword123', $user->password));
}
/**
* Test admin can view any user's password form
*
* @return void
*/
public function testAdminCanViewAnyPasswordForm(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->resetSession(); // Login as admin
$response = $this->get('/home/changePassword/' . $nonAdminId);
$response->assertStatus(200);
$response->assertSee('nonadmin');
}
/**
* Test admin can change any user's password
*
* @return void
*/
public function testAdminCanChangeAnyPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee(['username' => 'nonadminadminchange']);
$this->resetSession(); // Login as admin
$response = $this->post('/home/save/' . $nonAdminId, [
'username' => 'nonadminadminchange',
'current_password' => 'password123',
'password' => 'adminset123'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
// Verify password was changed
$employee = model(Employee::class);
$user = $employee->get_info($nonAdminId);
$this->assertTrue(password_verify('adminset123', $user->password));
}
/**
* Test default employee_id parameter uses current user
*
* @return void
*/
public function testDefaultEmployeeIdUsesCurrentUser(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
// Calling without employee_id should use current user
$response = $this->get('/home/changePassword');
$response->assertStatus(200);
$response->assertSee('nonadmin');
}
/**
* Test non-admin cannot view another non-admin's password form
* IDOR vulnerability fix: GHSA-mcc2-8rp2-q6ch
*
* @return void
*/
public function testNonAdminCannotViewOtherNonAdminPasswordForm(): void
{
$nonAdminId1 = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId1);
$otherUserId = $this->createNonAdminEmployee([
'username' => 'otheruser',
'email' => 'other@test.com',
'password' => 'password456'
]);
$response = $this->get('/home/changePassword/' . $otherUserId);
$response->assertStatus(403);
}
/**
* Test non-admin cannot change another non-admin's password
* IDOR vulnerability fix: GHSA-mcc2-8rp2-q6ch
*
* @return void
*/
public function testNonAdminCannotChangeOtherNonAdminPassword(): void
{
$nonAdminId1 = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId1);
$victimId = $this->createNonAdminEmployee([
'username' => 'victimuser',
'email' => 'victim@test.com',
'password' => 'victimpass123'
]);
$response = $this->post('/home/save/' . $victimId, [
'username' => 'victimuser',
'current_password' => 'victimpass123',
'password' => 'hacked123456'
]);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
// Verify victim's password was NOT changed
$employeeModel = model(Employee::class);
$victim = $employeeModel->get_info($victimId);
$this->assertTrue(password_verify('victimpass123', $victim->password),
'Non-admin should not be able to change another non-admin password');
}
/**
* Regression test for GHSA-9gr6-4mm4-4wrq: Home::__construct() previously
* read the raw (single-decoded) URI segment to decide whether to skip
* Secure_Controller's module-grant check for 'logout'. A route whose
* double-decoded method name resolves to 'logout' must still be treated
* as logout consistently - using the router's fully-resolved method name
* removes any single-vs-double-decode mismatch as an attack surface.
*
* @return void
*/
public function testLogoutStillBypassesGrantCheckAfterFix(): void
{
$nonAdminId = $this->createNonAdminEmployee([
'username' => 'logoutnograntuser',
'email' => 'logoutnograntuser@test.com',
'grants' => []
]);
$this->loginAs($nonAdminId);
$response = $this->get('/home/logout');
$response->assertRedirectTo('login');
}
/**
* A non-'logout' Home method must still enforce Secure_Controller's
* module-grant check (i.e. parent::__construct() is not skipped) for a
* logged-in employee with no 'home' grant.
*
* @return void
*/
public function testNonLogoutMethodStillEnforcesModuleGrantCheck(): void
{
$nonAdminId = $this->createNonAdminEmployee([
'username' => 'nogranthome',
'email' => 'nogranthome@test.com',
'grants' => []
]);
$this->loginAs($nonAdminId);
$response = $this->get('/home/changePassword/' . $nonAdminId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
}