Skip to content

Commit 9566d08

Browse files
authored
fix(auth): guard the aggregate user-groups route and align 10 auth annotations (#456)
TWO SEPARATE THINGS, both authorisation. 1. A PRIVILEGE BYPASS BY SIBLING ROUTE. Four routes each return one slice of the user-groups configuration and each carries an explicit `isAdmin() === false -> 403` guard: GET /api/settings/user-groups/generic GET /api/settings/user-groups/organization-admin GET /api/settings/user-groups/super-user GET /api/settings/user-groups/all A fifth returns all four at once and checked only that the caller was logged in: GET /api/user-groups/config -> SettingsController::getUserGroupsConfig() SettingsService::getUserGroupsConfig() is literally the union of the four guarded getters, so any authenticated user could read through it exactly what the four dedicated routes refuse them — including `allGroups`, the full group list of the instance. Which side to fix was NOT obvious from the finding. The four guarded getters have ZERO callers in src/: every consumer, including UserGroupsConfiguration.vue via the settings store, hits /api/user-groups/config. Read as "dead code", the tempting move is to delete the four guarded endpoints — which would have left the UNGUARDED aggregate as the only surviving reader. Tracing the sibling seam gives the opposite answer: the four are the correct implementation, and the live route is the one missing the guard. So the guard was added there, and to updateUserGroupsConfig() alongside it, which was admin-only through the ABSENCE of @NoAdminRequired — middleware alone, with no in-body backstop, unlike every one of its siblings. Neither gate could see this. gate-7 (no-admin-idor) counts `getUser() === null` as an auth guard and passes. gate-9 (semantic-auth) compares the annotation against the body and finds no mismatch, because a @NoAdminRequired method with no admin check is self-consistent. The defect lives in the relationship between two endpoints, which neither gate models. 2. TEN ANNOTATION/BODY MISMATCHES (gate-9, full-tree). Eight SettingsController methods declared @NoAdminRequired while their bodies return 403 to non-admins. Their own sibling setters (setGenericUserGroups, setSuperUserGroups, ...) already omit the annotation, so the file's intended pattern was unambiguous: drop it, and let the middleware refuse before the controller runs. Same effective policy, enforced one layer earlier. Two more — AanbodController::getAanbod and AangebodenGebruikController::getGebruiksWhereAfnemer — carried @publicpage while their specs (REQ-009, REQ-004) require an authenticated caller and their bodies enforce it. These are NOT the self-authenticating webhook/portal shape that makes gate-9's advice dangerous: nothing authenticates the caller from the request, the body tests the SESSION, so @publicpage only ever admitted callers the body would then reject. Replaced with @NoAdminRequired; the in-body guard stays as deny-before-grant. Full-tree gate-9: 10 findings -> PASS. EVIDENCE. SettingsControllerUserGroupsConfigAuthTest, five arms: non-admin refused (403, payload absent, service never consulted), admin still served the config itself and not merely a 200, anonymous still 401 rather than promoted to 403, and both arms again for the write half. Reverting SettingsController.php to origin/development turns exactly the two non-admin arms red — "Failed asserting that 200 is identical to 403" — i.e. a non-admin received the configuration. The deny-before-grant check uses a call counter, not expects($this->never()): the controller wraps its body in catch (\Exception), which swallows a PHPUnit expectation failure into a 500 and reports the leak as an unrelated server error. Measured — the first version of this test failed with "500 is identical to 403". phpcs: lib/ is 0 errors / 87 warnings, unchanged. Unit suite: 486 green.
1 parent 8bf2436 commit 9566d08

4 files changed

Lines changed: 380 additions & 15 deletions

File tree

lib/Controller/AanbodController.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,16 @@ public function __construct(
8080
*
8181
* @return JSONResponse JSON response with aanbod objects array
8282
*
83+
* REQ-009 requires an authenticated caller, and the body enforces it.
84+
* The PublicPage annotation told Nextcloud's middleware to admit
85+
* anonymous requests anyway, so declared and enforced contract disagreed:
86+
* every anonymous call was admitted, routed, and only then rejected by
87+
* the controller. @NoAdminRequired is the annotation that matches —
88+
* any logged-in user, rejected at the middleware. The in-body guard
89+
* stays as deny-before-grant (REQ-001).
90+
*
8391
* @NoCSRFRequired
84-
* @PublicPage
92+
* @NoAdminRequired
8593
*
8694
* @spec openspec/specs/aanbod-listings/spec.md
8795
* @spec openspec/specs/vendor-visibility-rbac/spec.md#requirement-the-aanbod-listing-endpoint-must-require-authentication-explicitly-not-implicitly-req-009

lib/Controller/AangebodenGebruikController.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,18 @@ public function __construct(
8787
*
8888
* @return JSONResponse JSON response with gebruiks array where org is afnemer
8989
*
90+
* REQ-004 requires an authenticated caller, and the body enforces it.
91+
* The PublicPage annotation told Nextcloud's middleware to admit
92+
* anonymous requests anyway, so declared and enforced contract disagreed:
93+
* every anonymous call was admitted, routed, and only then rejected by
94+
* the controller. @NoAdminRequired is the annotation that matches —
95+
* any logged-in user, rejected at the middleware. The in-body guard
96+
* stays as deny-before-grant (REQ-001).
97+
*
9098
* @NoCSRFRequired
91-
* @PublicPage
92-
* @spec openspec/specs/aangeboden-gebruik-api/spec.md
93-
* @spec openspec/specs/vendor-visibility-rbac/spec.md#requirement-the-offered-usage-afnemer-endpoint-must-require-authentication-explicitly-not-implicitly-req-004
99+
* @NoAdminRequired
100+
* @spec openspec/specs/aangeboden-gebruik-api/spec.md
101+
* @spec openspec/specs/vendor-visibility-rbac/spec.md#requirement-the-offered-usage-afnemer-endpoint-must-require-authentication-explicitly-not-implicitly-req-004
94102
*/
95103
public function getGebruiksWhereAfnemer(): JSONResponse
96104
{

lib/Controller/SettingsController.php

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2290,7 +2290,10 @@ public function getEmailTemplateVariables(string $templateName): JSONResponse
22902290
/**
22912291
* Get generic user groups
22922292
*
2293-
* @NoAdminRequired
2293+
* Admin-only: the body rejects a non-admin with 403, so the endpoint
2294+
* must not declare @NoAdminRequired. Its sibling setGenericUserGroups()
2295+
* already omits it.
2296+
*
22942297
* @NoCSRFRequired
22952298
*
22962299
* @return JSONResponse Generic user groups
@@ -2387,7 +2390,10 @@ public function setGenericUserGroups(): JSONResponse
23872390
/**
23882391
* Get organization admin groups
23892392
*
2390-
* @NoAdminRequired
2393+
* Admin-only: the body rejects a non-admin with 403, so the endpoint
2394+
* must not declare @NoAdminRequired. Its sibling
2395+
* setOrganizationAdminGroups() already omits it.
2396+
*
23912397
* @NoCSRFRequired
23922398
*
23932399
* @return JSONResponse Organization admin groups
@@ -2484,7 +2490,10 @@ public function setOrganizationAdminGroups(): JSONResponse
24842490
/**
24852491
* Get super user groups
24862492
*
2487-
* @NoAdminRequired
2493+
* Admin-only: the body rejects a non-admin with 403, so the endpoint
2494+
* must not declare @NoAdminRequired. Its sibling setSuperUserGroups()
2495+
* already omits it.
2496+
*
24882497
* @NoCSRFRequired
24892498
*
24902499
* @return JSONResponse Super user groups
@@ -2581,7 +2590,10 @@ public function setSuperUserGroups(): JSONResponse
25812590
/**
25822591
* Get all user groups
25832592
*
2584-
* @NoAdminRequired
2593+
* Admin-only: the body rejects a non-admin with 403, and the payload is
2594+
* the full group list of the instance — an enumeration surface. The
2595+
* endpoint must not declare @NoAdminRequired.
2596+
*
25852597
* @NoCSRFRequired
25862598
*
25872599
* @return JSONResponse All user groups
@@ -2631,7 +2643,9 @@ public function getAllGroups(): JSONResponse
26312643
/**
26322644
* Clear ArchiMate import status
26332645
*
2634-
* @NoAdminRequired
2646+
* Admin-only: the body rejects a non-admin with 403, and importArchiMate()
2647+
* — the operation whose status this clears — is already admin-only.
2648+
*
26352649
* @NoCSRFRequired
26362650
*
26372651
* @return JSONResponse Clear result
@@ -2682,9 +2696,11 @@ public function clearArchiMateImportStatus(): JSONResponse
26822696
*
26832697
* @deprecated Use cancelArchiMateImport() instead.
26842698
*
2685-
* @NoAdminRequired
2699+
* Admin-only: the body rejects a non-admin with 403, and importArchiMate()
2700+
* — the process this kills — is already admin-only.
2701+
*
26862702
* @NoCSRFRequired
2687-
* @spec openspec/specs/settings-admin-controller/spec.md
2703+
* @spec openspec/specs/settings-admin-controller/spec.md
26882704
*/
26892705
public function killArchiMateImport(): JSONResponse
26902706
{
@@ -2728,7 +2744,9 @@ public function killArchiMateImport(): JSONResponse
27282744
* Cancel a running ArchiMate import
27292745
* This combines force clearing and process killing for complete cancellation
27302746
*
2731-
* @NoAdminRequired
2747+
* Admin-only: the body rejects a non-admin with 403, and importArchiMate()
2748+
* — the process this cancels — is already admin-only.
2749+
*
27322750
* @NoCSRFRequired
27332751
*
27342752
* @return JSONResponse Cancellation result
@@ -2773,7 +2791,8 @@ public function cancelArchiMateImport(): JSONResponse
27732791
/**
27742792
* Clear ArchiMate export status
27752793
*
2776-
* @NoAdminRequired
2794+
* Admin-only: the body rejects a non-admin with 403.
2795+
*
27772796
* @NoCSRFRequired
27782797
*
27792798
* @return JSONResponse Clear result
@@ -3322,18 +3341,30 @@ public function getObjectsStatistics(): JSONResponse
33223341
/**
33233342
* Get user groups configuration only
33243343
*
3325-
* @NoAdminRequired
3344+
* This endpoint returns the union of getGenericUserGroups(),
3345+
* getOrganizationAdminGroups(), getSuperUserGroups() and getAllGroups().
3346+
* Each of those is exposed on its own route behind an explicit
3347+
* `isAdmin() === false -> 403` guard, so this aggregate MUST carry the
3348+
* same guard: without it any authenticated user reads through it the
3349+
* data the four dedicated routes refuse them, and the guards on those
3350+
* routes protect nothing.
3351+
*
33263352
* @NoCSRFRequired
33273353
*
33283354
* @return JSONResponse User groups configuration
33293355
* @spec openspec/specs/settings-admin-controller/spec.md
33303356
*/
33313357
public function getUserGroupsConfig(): JSONResponse
33323358
{
3333-
if ($this->userSession->getUser() === null) {
3359+
$currentUser = $this->userSession->getUser();
3360+
if ($currentUser === null) {
33343361
return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
33353362
}
33363363

3364+
if ($this->groupManager->isAdmin($currentUser->getUID()) === false) {
3365+
return new JSONResponse(['message' => 'Admin privileges required'], Http::STATUS_FORBIDDEN);
3366+
}
3367+
33373368
try {
33383369
$config = $this->settingsService->getUserGroupsConfig();
33393370

@@ -3365,6 +3396,15 @@ public function getUserGroupsConfig(): JSONResponse
33653396
*/
33663397
public function updateUserGroupsConfig(): JSONResponse
33673398
{
3399+
$currentUser = $this->userSession->getUser();
3400+
if ($currentUser === null) {
3401+
return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
3402+
}
3403+
3404+
if ($this->groupManager->isAdmin($currentUser->getUID()) === false) {
3405+
return new JSONResponse(['message' => 'Admin privileges required'], Http::STATUS_FORBIDDEN);
3406+
}
3407+
33683408
try {
33693409
$data = $this->request->getParams();
33703410
$result = $this->settingsService->updateUserGroupsConfig($data);

0 commit comments

Comments
 (0)