Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/Auth/AclTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,23 @@ protected function _isPublic(array $params) {
$authentication = $this->_getAuth();

foreach ($authentication as $rule) {
if ($params['plugin'] && $params['plugin'] !== $rule['plugin']) {
continue;
if (!empty($params['plugin'])) {
if ($params['plugin'] !== $rule['plugin']) {
continue;
}
} else {
if (!empty($rule['plugin'])) {
continue;
}
}
if (!empty($params['prefix']) && $params['prefix'] !== $rule['prefix']) {
continue;
if (!empty($params['prefix'])) {
if ($params['prefix'] !== $rule['prefix']) {
continue;
}
} else {
if (!empty($rule['prefix'])) {
continue;
}
}
if ($params['controller'] !== $rule['controller']) {
continue;
Expand Down
27 changes: 25 additions & 2 deletions src/Auth/AuthUserTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ public function roles(): array {
/**
* Check if the current session has this role.
*
* Comparison is performed strictly after coercing both the expected role
* and the provided role values to string. Without this normalization, the
* loose `in_array()` semantics make e.g. `0 == 'admin'` evaluate to true
* (PHP < 8) and mixed-type role IDs from different sources (DB ints vs.
* Configure strings) would match by accident. String coercion preserves
* cross-type equivalence between numeric strings and ints (the common
* legacy case) while rejecting unrelated scalar/string juggling.
*
* @param mixed $expectedRole
* @param mixed|null $providedRoles
* @return bool Success
Expand All @@ -119,8 +127,23 @@ public function hasRole($expectedRole, $providedRoles = null) {
return false;
}

if (array_key_exists($expectedRole, $roles) || in_array($expectedRole, $roles)) {
return true;
if (!is_scalar($expectedRole)) {
return false;
}

$expectedRoleString = (string)$expectedRole;
foreach ($roles as $key => $role) {
// Only string keys are role aliases; auto-int keys would otherwise
// produce a false positive for `hasRole(0, [0 => 'admin'])`.
if (is_string($key) && $key === $expectedRoleString) {
return true;
}
if (!is_scalar($role)) {
continue;
}
if ((string)$role === $expectedRoleString) {
return true;
}
}

return false;
Expand Down
21 changes: 21 additions & 0 deletions tests/TestCase/Controller/Component/AuthUserComponentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,27 @@ public function testHasRole() {
$this->assertFalse($this->AuthUser->hasRole(3, [2, 4]));
}

/**
* Regression: role lookup must not accept type-juggled false positives.
*
* Without normalization, `in_array(0, ['admin'])` evaluates to `true` on
* PHP < 8 and `in_array('1abc', [1])` evaluates to `true` on any version
* — both of which would let an unrelated value masquerade as a real role.
*
* @return void
*/
public function testHasRoleRejectsLooseTypeJugglingMatches() {
$this->assertFalse($this->AuthUser->hasRole(0, ['admin', 'moderator']));
$this->assertFalse($this->AuthUser->hasRole('1abc', [1, 2, 3]));
$this->assertFalse($this->AuthUser->hasRole('admin', [0, 1, 2]));
$this->assertFalse($this->AuthUser->hasRole(null, ['admin']));
$this->assertFalse($this->AuthUser->hasRole(true, ['admin']));

// Numeric-string / int equivalence is preserved on purpose.
$this->assertTrue($this->AuthUser->hasRole('2', [1, 2, 3]));
$this->assertTrue($this->AuthUser->hasRole(2, ['1', '2', '3']));
}

/**
* @return void
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,64 @@ public function testIsPublicFail() {
$this->assertFalse($result);
}

/**
* Regression: a rule scoped to a plugin must not match a request without one.
*
* `Extras.Offers = "!superPrivate", *` defines a plugin-scoped rule.
* A request to non-plugin `Offers::view` previously matched it because
* `_isPublic()` short-circuited the plugin guard when the request had none.
*
* @return void
*/
public function testIsPublicDoesNotMatchPluginRuleForNonPluginRequest() {
$request = new ServerRequest([
'params' => [
'controller' => 'Offers',
'action' => 'view',
'plugin' => null,
'_ext' => null,
'pass' => [],
],
]);
$controller = $this->getControllerMock($request);
$registry = new ComponentRegistry($controller);
$this->component = new AuthenticationComponent($registry, $this->componentConfig);

$result = $this->component->isPublic();
$this->assertFalse($result);
}

/**
* Regression: a rule scoped to a prefix must not match a request without one.
*
* `Admin/Users = index` is prefix-scoped to `Admin`. A non-prefix request
* to `Users::index` is already covered by the unprefixed `Users` rule, but
* a non-prefix request to `Users::view` (which is allowed unprefixed too)
* must not pull in the Admin-scoped rule. We exercise the inverse path by
* requesting an action that is only allowed under the Admin prefix to make
* sure the prefix-scoped rule does not leak to non-prefix requests.
*
* @return void
*/
public function testIsPublicDoesNotMatchPrefixRuleForNonPrefixRequest() {
$request = new ServerRequest([
'params' => [
'controller' => 'MyTest',
'action' => 'myPublic',
'plugin' => null,
'prefix' => null,
'_ext' => null,
'pass' => [],
],
]);
$controller = $this->getControllerMock($request);
$registry = new ComponentRegistry($controller);
$this->component = new AuthenticationComponent($registry, $this->componentConfig);

$result = $this->component->isPublic();
$this->assertFalse($result);
}

/**
* @return void
*/
Expand Down
Loading