Skip to content

Commit bd9751a

Browse files
authored
fix(auth): four admin actions were reachable by any authenticated user (#502)
* fix(auth): four admin actions were reachable by any authenticated user Hand-read all thirteen no-admin-idor findings rather than treating the count as a work queue. One is a real IDOR, four are semantic-auth mismatches, two are genuinely guarded downstream, five take no object reference at all, and one is scoped through its receiver. The four semantic-auth ones are the serious half. SettingsController has a consistent convention — every mutator is admin-required and only readers carry the no-admin annotation — and these were the exceptions: - testEmailConnection: caller-supplied smtpHost/smtpPort reach a DSN and the server opens an outbound TCP connection to whatever was named. For a non-admin that is an SSRF and internal port-scan primitive. - updateEmailTemplate: the only write in the whole class carrying the annotation. It writes app configuration, and the stored HTML is rendered into real outbound mail, so any authenticated user could rewrite the templates every recipient receives — and mint unbounded email_template_* config rows besides. - syncOrganisations: triggers a register-wide write sync with a caller-chosen batch size. - exportArchiMate: exports the WHOLE register while its sibling exportOrgArchiMate exports one organisation — and the sibling has carried verifyOrgExportPermission all along. The broader endpoint was the unguarded one. The annotation is kept here deliberately: that helper grants organisation-admins too, which is the tier the admin UI relies on and which removing the annotation would drop. The three email-template reads move with their write. No frontend code calls any of those routes — the settings UI reads templates from the bulk settings payload — verified with a positive control on a route that IS called, so nothing breaks. getSbomImportStatus is the real IDOR: a caller-supplied moduleVersieUuid reached SbomImportService::getStatus(), whose lookup runs with rbac and multitenancy both off. Guarded with authorizeManage()'s read tier — the same two building blocks minus the editor-group requirement, since reading a status is not managing an import. Checked rather than assumed: the module schema carries a real read ACL, so this is a genuine scope and not the default-open case an authorization-less schema would give. Refuses 404, not 403, so it cannot become an existence oracle. Everything else carries a reason-bearing exemption naming the code path that makes it safe, not a state of the world. One finding is deliberately LEFT OPEN. getGebruikenForDeelnemer forces its organisation filter after getParams(), so a caller cannot forge it — but it passes an ARRAY where the app's canonical sibling passes a SCALAR, and the query runs with rbac and multitenancy off. Whether OpenRegister honours array-containment matching on a related-object array property is unverified, and if it silently ignores the array form that scope is vacuous. It could not be settled here: the available instance has zero gebruik rows, so a live A/B would have returned empty under both forms and proved nothing. It gets the fail-closed guard its canonical sibling already has, and keeps the finding. Exempting it would have manufactured the coverage. Nothing here makes anything return 200 that previously errored: every change is deny-only. exportArchiMate's dead $organization filter parameter is a real bug found on the way and is NOT repaired here — that is a feature repair sitting behind a missing guard, and it belongs after the guard lands. Refs #492. * fix(sbom): guard inside the try, not ahead of it My own regression, caught before it landed. authorizeRead() reaches SbomImportService::resolveParentModuleUuid(), and OpenRegister's real ObjectService::find() RE-THROWS DoesNotExistException for a well-formed but non-existent uuid rather than returning null — importSbom()'s docblock already records this and wraps its whole body for exactly that reason. Placing the guard ahead of the try would have converted this endpoint's clean 404 into a 500 for precisely the callers the guard was added for: a non-admin passing an unknown id. The guard now sits inside the try, so the not-found dialect is the same whichever call raises it. * test(auth): pin the auth posture of the six hardened endpoints The security-change-has-tests gate failed on the previous push, correctly: that change moved auth posture in lib/ and touched no test. This is the test, and it pins the near-miss rather than the diff. It parses the controller source with Nextcloud's OWN annotation regex, copied byte for byte from ControllerMethodReflector::reflect(). That is the point. While writing the fix, three of these methods documented their own hardening with the sentence "the endpoint must not declare @NoAdminRequired" — and that token, at the start of a comment line, MATCHES that regex. Nextcloud would have gone on treating the endpoint as non-admin-required: the sentence explaining the removal would have undone the removal, and the change would have read as a security fix while being a no-op. A test searching for the attribute form, or stripping comments first, would pass straight over it. Five tests. The first is a positive control asserting the parser CAN find the annotation where it legitimately remains (getSyncStatus) — without it, a typo in the regex or the docblock walk would make every absence assertion pass over an empty array and the file would be green while asserting nothing. exportArchiMate is pinned in BOTH directions, because the two pull opposite ways: it must KEEP the annotation, since verifyOrgExportPermission grants organisation-admins and removing it would drop that tier, AND it must call that helper. The right fix for its neighbours was the wrong fix for it. getSbomImportStatus is pinned for guard presence and for guard POSITION — inside the try, because authorizeRead() reaches ObjectService::find(), which re-throws for an unknown uuid, so guarding ahead of the try would turn a clean 404 into a 500 for exactly the callers the guard was added for. Proven able to fail, prediction written first: re-planting the prose form of the annotation reddens exactly testAdminActionsDoNotDeclareNoAdminRequired and nothing else. Reverted byte-identically. The positional assertion first failed on its own account, and the reason is worth keeping: it anchored on the bare name, which the controller's own explanatory comment uses ABOVE the try. A positional assertion over a corpus that includes prose measures the prose. It anchors on the call now.
1 parent ee2152d commit bd9751a

5 files changed

Lines changed: 449 additions & 7 deletions

File tree

lib/Controller/GebruikController.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,33 @@ public function getGebruikenForDeelnemer(): JSONResponse {
288288

289289
$orgUuid = $this->config->getUserValue(userId: $user->getUID(), appName: 'core', key: 'organisation');
290290

291+
// Fail CLOSED on a missing organisation, as the app's canonical
292+
// deelnemers-scoped read already does in
293+
// AangebodenGebruikService::getGebruiksWhereDeelnemers(). getUserValue()
294+
// returns '' rather than null when the user-value was never set, so
295+
// without this the scope below degrades to `deelnemers: ['']` — and the
296+
// query runs with `_rbac: false, _multitenancy: false`, so an empty
297+
// predicate that OpenRegister chooses to ignore would return every
298+
// organisation's gebruik data rather than none.
299+
if ($orgUuid === '') {
300+
return new JSONResponse(
301+
['message' => 'No organisation is set for this account'],
302+
Http::STATUS_FORBIDDEN
303+
);
304+
}
305+
306+
// The scope is forced AFTER getParams(), so a caller cannot supply or
307+
// override `deelnemers` and read another organisation's usage.
308+
//
309+
// ⚠️ OPEN, and deliberately not "fixed" by guessing: this passes an
310+
// ARRAY where the canonical sibling passes a SCALAR, and whether
311+
// OpenRegister honours array-containment matching on a `related-object`
312+
// array property is unverified. If it silently ignores the array form
313+
// this scope is vacuous. It could not be settled here — the instance
314+
// available has zero gebruik rows, so a live A/B would have returned
315+
// empty under both forms and proved nothing. Filed rather than changed:
316+
// swapping the filter form on a working endpoint could equally break
317+
// it, and an unmeasured change to a scope is not a fix.
291318
$options = $this->request->getParams();
292319
$options['deelnemers'] = [$orgUuid];
293320

lib/Controller/ReviewController.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ public function submit(array $review = [], string $subjectType = '', string $sub
108108
*
109109
* @PublicPage
110110
* @NoCSRFRequired
111+
* @no-admin-idor-exempt The lookup IS constrained to the scope this
112+
* endpoint declares public, which is the remedy for
113+
* `publicpage-unscoped-object-lookup` (there is no session identity on
114+
* a public page, so an ownership check is not available and not the
115+
* fix). `$subjectId` never reaches storage: the query in
116+
* ReviewAggregateService::fetchApprovedReviews() is keyed on
117+
* `@self.register` / `@self.schema` plus `status = approved` — with
118+
* the metadata filters correctly nested under `@self` — and the
119+
* subject is matched in memory afterwards. `status` is then re-checked
120+
* in PHP over every row, which that method's own comment names as the
121+
* real enforcement point because `_rbac: false` bypasses
122+
* OpenRegister's enforcement of the predicate. So an arbitrary
123+
* `subjectId` can only ever select from already-approved reviews.
111124
* @spec openspec/specs/catalog-ratings/spec.md#requirement-module-and-dienst-detail-pages-must-display-an-aggregate-rating-computed-only-from-approved-reviews
112125
*/
113126
#[PublicPage]

lib/Controller/SbomController.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,20 @@ public function getSbomImportStatus(string $moduleVersieUuid): JSONResponse {
284284
$operationId = null;
285285
}
286286

287+
// The guard goes INSIDE the try, for the reason importSbom()'s docblock
288+
// already records: authorizeRead() reaches
289+
// SbomImportService::resolveParentModuleUuid(), and OpenRegister's real
290+
// ObjectService::find() RE-THROWS DoesNotExistException for a
291+
// well-formed but non-existent uuid rather than returning null. Guarding
292+
// outside the try would therefore turn this endpoint's clean 404 into a
293+
// 500 for exactly the callers the guard was added for — non-admins
294+
// passing an unknown id.
287295
try {
296+
$readError = $this->authorizeRead(moduleVersieUuid: $moduleVersieUuid);
297+
if ($readError !== null) {
298+
return $readError;
299+
}
300+
288301
return new JSONResponse(
289302
data: $this->importService->getStatus(
290303
moduleVersieUuid: $moduleVersieUuid,
@@ -299,6 +312,62 @@ public function getSbomImportStatus(string $moduleVersieUuid): JSONResponse {
299312
}
300313
}//end getSbomImportStatus()
301314

315+
/**
316+
* Read-ACL authorization guard (IDOR guard) for the import-status read.
317+
*
318+
* `getSbomImportStatus()` took a caller-supplied `moduleVersieUuid` all the
319+
* way to `SbomImportService::getStatus()`, whose lookup runs with
320+
* `_rbac: false, _multitenancy: false` — so the object reference was never
321+
* scoped to the caller and any authenticated user could read any
322+
* moduleVersie's import provenance by substituting a uuid.
323+
*
324+
* This is `authorizeManage()`'s read tier: the same two building blocks
325+
* (`resolveParentModuleUuid` + `userCanReadModule`) minus the editor-group
326+
* membership requirement, because reading a status is not managing an
327+
* import. `userCanReadModule()` resolves with `_rbac: true,
328+
* _multitenancy: true`, and — checked rather than assumed — the `module`
329+
* schema carries a real `authorization.read` ACL (group tiers plus an
330+
* `_organisation` match), so this is a genuine scope rather than the
331+
* default-open case an authorization-less schema would give.
332+
*
333+
* Refuses with 404, not 403, deliberately: the sibling not-found path
334+
* already answers 404 for an unknown uuid, and matching it keeps the
335+
* endpoint from becoming an existence oracle for other tenants' ids.
336+
*
337+
* @param string $moduleVersieUuid The target moduleVersie's uuid.
338+
*
339+
* @return JSONResponse|null Error response, or null when authorized.
340+
*
341+
* @spec openspec/specs/sbom-import/spec.md#requirement-moduleversie-records-sbom-import-provenance
342+
*/
343+
private function authorizeRead(string $moduleVersieUuid): ?JSONResponse {
344+
$user = $this->userSession->getUser();
345+
if ($user === null) {
346+
return new JSONResponse(data: ['message' => 'Not logged in'], statusCode: Http::STATUS_UNAUTHORIZED);
347+
}
348+
349+
if ($this->groupManager->isAdmin($user->getUID()) === true) {
350+
return null;
351+
}
352+
353+
$moduleUuid = $this->importService->resolveParentModuleUuid($moduleVersieUuid);
354+
if ($moduleUuid === null || $this->importService->userCanReadModule($moduleUuid) === false) {
355+
$this->logger->warning(
356+
'SbomController: status read refused (no read access to the parent module)',
357+
['moduleVersieUuid' => $moduleVersieUuid, 'uid' => $user->getUID()]
358+
);
359+
return new JSONResponse(
360+
data: [
361+
'message' => 'moduleVersie not found: ' . $moduleVersieUuid,
362+
'error' => 'MODULE_VERSION_NOT_FOUND',
363+
],
364+
statusCode: Http::STATUS_NOT_FOUND
365+
);
366+
}
367+
368+
return null;
369+
}//end authorizeRead()
370+
302371
/**
303372
* Manage-ACL authorization guard (IDOR guard). Returns a JSONResponse to
304373
* short-circuit on failure, or null when the caller may import.

lib/Controller/SettingsController.php

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,11 @@ public function sendTestEmail(): JSONResponse {
827827
*
828828
* @NoAdminRequired
829829
* @NoCSRFRequired
830+
* @no-admin-idor-exempt Takes no object reference. The only parameter is a
831+
* typed `int $minutesBack` time window, and
832+
* OrganizationSyncService::getSyncStatusWithErrorHandling() accepts no
833+
* object id either — it reports aggregate sync timing, not a record.
834+
* There is nothing for a caller to substitute.
830835
*
831836
* @spec openspec/specs/method-decomposition/spec.md
832837
*/
@@ -910,6 +915,10 @@ public function performSync(int $minutesBack = 0): JSONResponse {
910915
*
911916
* @NoAdminRequired
912917
* @NoCSRFRequired
918+
* @no-admin-idor-exempt An availability probe that touches no storage. The
919+
* body reads one `timestamp` request param, logs it at debug level and
920+
* echoes it back; there is no mapper, service or object lookup on the
921+
* path at all, so there is no direct object reference to substitute.
913922
*
914923
* @spec openspec/specs/method-decomposition/spec.md
915924
*/
@@ -1591,10 +1600,22 @@ private function resolveArchiMateMethod(array $options): array {
15911600
* @spec openspec/specs/settings-admin-controller/spec.md
15921601
*/
15931602
public function exportArchiMate(): Response {
1594-
if ($this->userSession->getUser() === null) {
1603+
$currentUser = $this->userSession->getUser();
1604+
if ($currentUser === null) {
15951605
return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
15961606
}
15971607

1608+
// Same guard as the sibling exportOrgArchiMate(), which has carried it
1609+
// all along. This endpoint exports the WHOLE register while the sibling
1610+
// exports one organisation, so it was the broader of the two and the
1611+
// only one unguarded. @NoAdminRequired is kept deliberately: the helper
1612+
// grants organisation-admins as well as admins, which is the tier the
1613+
// admin UI relies on and which the annotation's removal would drop.
1614+
$permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser);
1615+
if ($permissionError !== null) {
1616+
return $permissionError;
1617+
}
1618+
15981619
try {
15991620
$rawInput = file_get_contents('php://input');
16001621
$data = json_decode($rawInput, true);
@@ -1771,6 +1792,13 @@ public function render(): string {
17711792
*
17721793
* @NoAdminRequired
17731794
* @NoCSRFRequired
1795+
* @no-admin-idor-exempt The scope is in the RECEIVER, not in an argument.
1796+
* `$fileName` is resolved against
1797+
* `IRootFolder::getUserFolder($userSession->getUser()->getUID())`, so
1798+
* it can only ever name a file in the caller's own home; and `..` and
1799+
* `/` are rejected outright before that, so it cannot escape it. A
1800+
* substituted filename reaches a different file only if the caller
1801+
* already owns that file.
17741802
* @spec openspec/specs/settings-admin-controller/spec.md
17751803
*/
17761804
public function downloadArchiMate(string $fileName): Response {
@@ -1868,7 +1896,13 @@ public function downloadArchiMate(string $fileName): Response {
18681896
/**
18691897
* Test email connection (separate from sending test email)
18701898
*
1871-
* @NoAdminRequired
1899+
* Admin-only: the caller-supplied smtpHost/smtpPort reach a DSN in
1900+
* SettingsService::testEmailConnection(), and the server then opens an
1901+
* outbound TCP connection to whatever host and port were named. For a
1902+
* non-admin that is an SSRF and internal port-scan primitive, so the
1903+
* endpoint must not declare @NoAdminRequired. Its sibling sendTestEmail()
1904+
* already omits it, as does every other action on this controller.
1905+
*
18721906
* @NoCSRFRequired
18731907
*
18741908
* @return JSONResponse Test connection result
@@ -2083,7 +2117,12 @@ public function getEmailTemplates(): JSONResponse {
20832117
*
20842118
* @return JSONResponse Template content.
20852119
*
2086-
* @NoAdminRequired
2120+
* Admin-only, to match the write it pairs with. It reads admin-authored
2121+
* mail templates out of app configuration, and no frontend code calls this
2122+
* route at all — the settings UI reads templates from the bulk settings
2123+
* payload instead (verified with a positive control on a route that IS
2124+
* called). Leaving it open bought nothing and exposed admin content.
2125+
*
20872126
* @NoCSRFRequired
20882127
* @spec openspec/specs/settings-admin-controller/spec.md
20892128
*/
@@ -2126,7 +2165,14 @@ public function getEmailTemplate(string $templateName): JSONResponse {
21262165
*
21272166
* @return JSONResponse Update result.
21282167
*
2129-
* @NoAdminRequired
2168+
* Admin-only: this writes app configuration —
2169+
* setValueString($appName, "email_template_{$templateName}", $content) —
2170+
* and the stored HTML is rendered into real outbound mail. It was the only
2171+
* write on this controller declaring @NoAdminRequired, so any authenticated
2172+
* user could rewrite the templates every recipient receives, and mint
2173+
* unbounded `email_template_*` config rows besides. Its sibling
2174+
* updateEmailSettings() already omits the annotation.
2175+
*
21302176
* @NoCSRFRequired
21312177
* @spec openspec/specs/settings-admin-controller/spec.md
21322178
*/
@@ -2185,7 +2231,9 @@ public function updateEmailTemplate(string $templateName): JSONResponse {
21852231
*
21862232
* @return JSONResponse Default template content.
21872233
*
2188-
* @NoAdminRequired
2234+
* Admin-only, for the same reason as getEmailTemplate(): it belongs to the
2235+
* admin mail-template surface and has no non-admin consumer.
2236+
*
21892237
* @NoCSRFRequired
21902238
* @spec openspec/specs/settings-admin-controller/spec.md
21912239
*/
@@ -2228,7 +2276,9 @@ public function getEmailTemplateDefault(string $templateName): JSONResponse {
22282276
*
22292277
* @return JSONResponse Available variables for template.
22302278
*
2231-
* @NoAdminRequired
2279+
* Admin-only, for the same reason as getEmailTemplate(): it belongs to the
2280+
* admin mail-template surface and has no non-admin consumer.
2281+
*
22322282
* @NoCSRFRequired
22332283
* @spec openspec/specs/settings-admin-controller/spec.md
22342284
*/
@@ -3448,7 +3498,20 @@ private function getHttpStatusForErrorMessage(string $message): int {
34483498
/**
34493499
* Sync OpenRegister organisations to voorzieningen register
34503500
*
3451-
* @NoAdminRequired
3501+
* Admin-only: this triggers a register-wide write sync with a
3502+
* caller-chosen batch size, so the endpoint must not carry the
3503+
* no-admin-required annotation. Its siblings performSync(),
3504+
* bulkSyncStandards() and triggerEolSync() already omit it — the
3505+
* annotation's absence IS the check on this controller, which is why none
3506+
* of them carries an in-body one.
3507+
*
3508+
* The annotation is deliberately NOT spelled out above. Nextcloud's own
3509+
* ControllerMethodReflector matches `^\h+\*\h+@([A-Z]\w+)` against the raw
3510+
* docblock, so writing "must not declare @NoAdminRequired" at the start of
3511+
* a comment line RE-DECLARES it — the sentence explaining the removal
3512+
* undoes the removal, in the framework and not merely in a linter. Gate-7
3513+
* caught it here; nothing else would have.
3514+
*
34523515
* @NoCSRFRequired
34533516
*
34543517
* @return JSONResponse The sync results

0 commit comments

Comments
 (0)