fix(auth): guard the aggregate user-groups route — a non-admin read what four guarded routes refuse (+10 gate-9 mismatches) - #456
Merged
Conversation
…notations
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.
Contributor
Quality Report — ConductionNL/softwarecatalog @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 128/128 | |||
| npm | ✅ | ✅ 718/718 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-08 11:15 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 → 403guard:A fifth returns all four at once and checked only that the caller was logged in:
SettingsService::getUserGroupsConfig()is literally the union of the four guarded getters. Any authenticated user could read through it exactly the data the four dedicated routes refuse them — includingallGroups, 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 —UserGroupsConfiguration.vueviasettings.js— hits/api/user-groups/config.Read as "dead code", the tempting move is to delete the four guarded endpoints. That 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 to the live route, and to
updateUserGroupsConfig()alongside it — that one was admin-only through the absence of@NoAdminRequired, i.e. middleware alone with no in-body backstop, unlike every one of its siblings.Why no gate caught it
getUser() === nullas an auth guard, and passes.@NoAdminRequiredmethod 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, measured full-tree)
Eight
SettingsControllermethods declared@NoAdminRequiredwhile 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::getAanbodandAangebodenGebruikController::getGebruiksWhereAfnemer— carried@PublicPagewhile 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.
@PublicPagetherefore only ever admitted callers the body would then reject — every anonymous call was admitted, routed, and rejected in the controller. Replaced with@NoAdminRequired; the in-body guard stays as deny-before-grant.Full-tree gate-9: 10 findings → PASS, re-measured with the runner from
ConductionNL/.github@mainover the whole tree, not a diff scope.Evidence
SettingsControllerUserGroupsConfigAuthTest, five arms:Can-fail proof: reverting
SettingsController.phptoorigin/developmentturns exactly the two non-admin arms red —Failed asserting that 200 is identical to 403. That is a non-admin receiving the configuration.A measured detail worth keeping. The deny-before-grant check uses a call counter, not
expects($this->never()). The controller wraps its body incatch (\Exception), which swallows a PHPUnit expectation failure into a 500 and reports the leak as an unrelated server error — the first version of this test failed with500 is identical to 403, which names the wrong defect.Checks
phpcs --standard=phpcs.xml lib/phpunit -c phpunit-unit.xmlOne thing deliberately not changed
getArchiMateConfig(),getArchiMateSettings()andtestArchiMateRoundTrip()are@NoAdminRequiredwith no admin guard, whileimportArchiMate()andupdateArchiMateConfig()— the write halves — are admin-only. The read side of an admin-panel concern being open to any authenticated user is an inconsistency, but "who may read the AMEF register/schema configuration" is a product decision, not an obvious defect, so it is reported rather than changed here.