Skip to content

Commit de50166

Browse files
committed
fix: prevent privilege escalation via group rights assignment
1 parent 7c3d8ae commit de50166

2 files changed

Lines changed: 123 additions & 1 deletion

File tree

phpmyfaq/src/phpMyFAQ/Controller/Administration/GroupController.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,22 @@ public function updatePermissions(Request $request): Response
302302
}
303303

304304
$groupId = (int) Filter::filterVar($request->request->get('group_id'), FILTER_VALIDATE_INT);
305-
$groupPermissions = $request->request->all()['group_rights'];
305+
$groupPermissions = $request->request->all()['group_rights'] ?? [];
306+
if (!is_array($groupPermissions)) {
307+
$groupPermissions = [];
308+
}
309+
310+
// A non-SuperAdmin may only assign rights they hold themselves. This prevents an
311+
// administrator with the delegable GROUP_EDIT right from granting privileges they do not
312+
// possess to a group (privilege escalation via group membership inheritance).
313+
if (!$this->currentUser->isSuperAdmin()) {
314+
$actingUserId = $this->currentUser->getUserId();
315+
foreach ($groupPermissions as $groupPermission) {
316+
if (!$this->currentUser->perm->hasPermission($actingUserId, (int) $groupPermission)) {
317+
throw new UnauthorizedHttpException('Cannot grant a right you do not hold');
318+
}
319+
}
320+
}
306321

307322
$user = $this->container->get(id: 'phpmyfaq.user');
308323
if (!$user->perm->refuseAllGroupRights($groupId)) {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace phpMyFAQ\Controller\Administration;
4+
5+
use phpMyFAQ\Permission\PermissionInterface;
6+
use phpMyFAQ\Session\Token;
7+
use phpMyFAQ\User\CurrentUser;
8+
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
9+
use PHPUnit\Framework\TestCase;
10+
use ReflectionClass;
11+
use ReflectionProperty;
12+
use Symfony\Component\DependencyInjection\ContainerBuilder;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpFoundation\Session\Session;
15+
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
16+
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
17+
18+
#[AllowMockObjectsWithoutExpectations]
19+
class GroupControllerTest extends TestCase
20+
{
21+
protected function setUp(): void
22+
{
23+
$instance = new ReflectionProperty(Token::class, 'instance');
24+
$instance->setValue(null, null);
25+
$_COOKIE = [];
26+
}
27+
28+
protected function tearDown(): void
29+
{
30+
$instance = new ReflectionProperty(Token::class, 'instance');
31+
$instance->setValue(null, null);
32+
$_COOKIE = [];
33+
}
34+
35+
private function buildController(Session $session, CurrentUser $actingUser): GroupController
36+
{
37+
$controller = (new ReflectionClass(GroupController::class))->newInstanceWithoutConstructor();
38+
39+
$container = $this->createMock(ContainerBuilder::class);
40+
$container
41+
->method('get')
42+
->willReturnCallback(static function (string $id) use ($session) {
43+
return $id === 'session' ? $session : null;
44+
});
45+
46+
$reflection = new ReflectionClass(GroupController::class);
47+
$parent = $reflection->getParentClass();
48+
while ($parent !== false && !$parent->hasProperty('currentUser')) {
49+
$parent = $parent->getParentClass();
50+
}
51+
52+
$parent->getProperty('container')->setValue($controller, $container);
53+
$parent->getProperty('currentUser')->setValue($controller, $actingUser);
54+
55+
return $controller;
56+
}
57+
58+
private function primeCsrf(Session $session, string $page): string
59+
{
60+
$tokenValue = 'unit-test-token-' . bin2hex(random_bytes(8));
61+
$cookieName = 'pmf-csrf-token-' . substr(md5($page), 0, 10);
62+
63+
$reflection = new ReflectionClass(Token::class);
64+
$token = $reflection->newInstanceWithoutConstructor();
65+
$reflection->getProperty('session')->setValue($token, $session);
66+
$token->setPage($page);
67+
$token->setExpiry(time() + 3600);
68+
$token->setSessionToken($tokenValue);
69+
$token->setCookieToken($tokenValue);
70+
71+
$session->set('pmf-csrf-token.' . $page, $token);
72+
$_COOKIE[$cookieName] = $tokenValue;
73+
74+
return $tokenValue;
75+
}
76+
77+
public function testUpdatePermissionsNonSuperAdminCannotGrantRightTheyDoNotHold(): void
78+
{
79+
$session = new Session(new MockArraySessionStorage());
80+
81+
// Acting user holds the permission gates (string-keyed rights such as 'editgroup')
82+
// but does NOT hold right id 42, which it tries to grant to the group.
83+
$perm = $this->createMock(PermissionInterface::class);
84+
$perm->method('hasPermission')->willReturnCallback(
85+
static fn(int $userId, mixed $right): bool => is_string($right),
86+
);
87+
88+
$actingUser = $this->createMock(CurrentUser::class);
89+
$actingUser->perm = $perm;
90+
$actingUser->method('getUserId')->willReturn(5);
91+
$actingUser->method('isSuperAdmin')->willReturn(false);
92+
93+
$controller = $this->buildController($session, $actingUser);
94+
$csrf = $this->primeCsrf($session, 'update-group-permissions');
95+
96+
$request = new Request(
97+
request: [
98+
'pmf-csrf-token' => $csrf,
99+
'group_id' => '2',
100+
'group_rights' => ['42'], // an int right the acting user does not hold
101+
],
102+
);
103+
104+
$this->expectException(UnauthorizedHttpException::class);
105+
$controller->updatePermissions($request);
106+
}
107+
}

0 commit comments

Comments
 (0)