fix(security): authorise citizen lookups and the AI decision trail (gate-7 IDORs, tranche 1) - #805
Merged
Merged
Conversation
…ate-7) `GET /api/kcc/voorblad?burgerId=…` returned a citizen's recent contact history — caller phone number and the free-text summary of every previous call — to ANY authenticated account. Reproduced live on a running instance with two accounts, status codes printed: unrelated non-admin got HTTP 200 before, HTTP 403 after; a member of group `kcc` still gets 200. gate-7 has always reported 0 for this app. `.github#365`: the checker accepts `Http::STATUS_UNAUTHORIZED` / `401` as an authorisation guard, and 49 of procest's 91 controller files open with that authentication preamble. The silence was never a verdict. What changed - `CitizenLookupGuard` (new): role gate for the endpoints that resolve a raw `burgerId`. These have no per-object owner — a KCC handler legitimately answers a call from a citizen they have never handled — so the control is a role, not a per-case relationship, and `CaseAccessGuard` does not apply. Fails closed in the opposite direction to the bug `CaseAccessGuard` was written for: an absent group grants nothing. Applied to voorblad, contactmomenten index+create, nieuwe-zaak, klacht-registreren. - `CaseAccessGuard::hasCaseReadAccess()` (new): per-case READ predicate — admin, `assignee`, or a member of `assignees`. Deliberately not delegated to `Sharing\CaseAccessPolicy::canUserAccessCase()`, which returns TRUE when OpenRegister is absent, when the schema is unconfigured, and when the lookup throws; three fail-open branches this guard must not inherit. - `AiController::auditIndex()`: `caseId` was optional and the filter was built with `array_filter()`, so omitting it returned every AI decision record on the instance. Now required, and authorised with `hasCaseReadAccess()`. - `TenantMiddleware`: removed three lines calling `IRequest::setParameter()`, a method that exists on no Nextcloud request class. Every request from a user who HAD a tenant died as an unhandled `Error` → HTTP 500 + an HTML page. Single-tenant installs return earlier, so neither CI nor any e2e rig ever reached it, and there is no unit test for this middleware. Nothing read the three keys back. The tenant-lifecycle enforcement above it is untouched. - `composer.lock`: phpcsutils 1.2.2 → 1.2.3 (CVE-2026-65954). Tests - 7 new controller cases + 9 new guard cases + 2 new auditIndex cases. - Negative control: inverting the guard predicate in ContactMomentController turns all 7 red (7 failures, 7 assertions), including the two positive arms; reverted. - Full suite 1795 tests OK; phpcs 0 errors over lib/; psalm, phpstan, phpmd clean. Refs ConductionNL/.github#365, ConductionNL/.github#372
rubenvdlinde
requested review from
Rem-Dam,
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
August 11, 2026 20:54
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 21:10 UTC
Download the full PDF report from the workflow artifacts.
…sGuard
Tranche 2 of procest#801. Twenty more `#[NoAdminRequired]` endpoints that took
a case (or an object resolving to a case) off the request and acted on it with
no per-object authorisation now call the guard the app already has.
The ownership field is not invented: `case.assignee` / `case.assignees` is the
model `CaseAccessGuard` already encodes and `WOOAssessmentController` already
enforces. Nothing here introduces a new notion of who owns a case.
Guarded
- CaseRelation list/create/destroy — read access to BOTH ends, which is what
the docblocks already claimed. Their guard was correctly shaped and INERT:
it keys off `find()` returning null, and OpenRegister returns every object
to every authenticated user for a schema with no `authorization` block
(.github#372). Every false docblock is corrected in this commit, including
the one in CaseRelationService, with a note not to re-state the RBAC claim
unless a test fails when the declaration is removed.
- Deelzaak list/parent/validate (read) and unlink (mutation — mass-detaches
every sub-case of a parent).
- Berichtenbox send (mutation) / messages / poll. `send()` dispatches an
official government message into a citizen's statutory message box.
- Appointment index/create/cancel/noShow.
- Zaakdossier uploadDocument — its sibling `linkExisting()` guards the
DOCUMENT; the missing half was the CASE.
- StatusTransition available/history, Milestone progress, DossierExport export,
EmailTemplate prefillDraft, Lhs recommend (persists an enforcement-sanction
record, so guarded as a mutation despite reading like a query).
- BezwaarHearing recordAttendance.
- Advice dispatchReminder: `AdviceService` grows an authorised
`dispatchReminderAsUser()` seam in front of the unguarded
`dispatchReminder()` the cron uses — the same split this class already has
between `transitionStatus()` and `applyTransition()`. The cron keeps working
without a session.
- EmailTemplate create/update/seedDefaults are admin-only. These are config
writes, not case data — a caseType belongs to the municipality, not to a
handler, so there is no per-case relationship to authorise against.
seedDefaults' docblock argued its posture "mirrors createTemplate()
deliberately"; that argument was sound and the posture it mirrored was not.
Where a route carries only a child id (message, appointment, hearing session)
the owning case is resolved first via a new `getCaseIdFor*()` on the relevant
service. An unresolvable child DENIES, so none of these is an existence oracle.
Read access is `hasCaseReadAccess()`, deliberately not
`Sharing\CaseAccessPolicy::canUserAccessCase()` — that one returns TRUE when
OpenRegister is absent, when the schema is unconfigured, and when the lookup
throws.
Verified the callee signature before relying on it: `ObjectService::find()`
declares `id`, `register` and `schema`, so the named-argument call inside
`CaseAccessGuard::loadCase()` cannot raise the `Error` that its
`catch (Throwable) { return null; }` would convert into "deny everyone".
Tests: 1802 OK. Five existing test classes updated for the new constructor
arguments — each keeps its original assertions and gains a refusal arm.
Refs ConductionNL/.github#365, ConductionNL/.github#372, procest#801
…ngs my guards added CI caught what my local run missed: I ran phpmd at the tranche-1 checkpoint and not again before committing tranche 2, and the three `getCaseIdFor*()` resolvers I had written were near-identical copies of one another. - `Service\Support\OwningCaseResolver` (new) — one fail-closed lookup from a child object to its owning case. `AppointmentService::getCaseIdForAppointment`, `BerichtenboxService::getCaseIdForMessage` and `Bezwaar\HearingService::getCaseIdForSession` now delegate to it. Fixes CyclomaticComplexity 10 and NPathComplexity 288 on two of them and ExcessiveClassComplexity 53 on HearingService, by deleting the duplication rather than by loosening a threshold. It uses `is_callable()` rather than `method_exists()` for `jsonSerialize`: OpenRegister's `ObjectEntity` reaches several accessors through `Entity::__call()`, for which `method_exists()` is false. - The upload case-guard moves from `ZaakdossierController` to `DossierUploadHandler::hasCaseUploadAccess()` — the collaborator that performs the write. That also takes the controller back under the CouplingBetweenObjects threshold my added dependency had pushed it over. Evaluated once per request, not once per file. No gate threshold, baseline or test was weakened; phpmd is now silent on both rulesets.⚠️ Discovered while live-verifying the resolver's POSITIVE path: `POST /api/bezwaar/hearings/{sessionId}/attendance` HAS NEVER WORKED. `HearingService::recordAttendance()` does `$current = $objectService->find(...); if (is_array($current) === false) throw`, but `find()` returns `?ObjectEntity`, so the throw is unconditional for an existing session. The owner gets `400 Hearing session not found`. That endpoint was therefore NOT a live IDOR — it is a dead endpoint, and this is recorded rather than repaired: it is a legally-sensitive Awb art. 7:7 audit path and fixing it would change behaviour I cannot validate here. The guard added in the previous commit stays, so whoever repairs the lookup cannot turn it into a live IDOR in the process. Refs ConductionNL/.github#365, procest#801
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 21:37 UTC
Download the full PDF report from the workflow artifacts.
…ment CI's phpcs found what I did not, for the second time in this branch and for the same reason: after the OwningCaseResolver refactor I re-ran phpmd but not phpcs, having assumed the earlier full-gate run still covered the tree. - `DossierUploadHandler`: missing `@param` for `$caseAccessGuard` (a real error, not alignment). - `ZaakdossierController`, `AppointmentService`, `OwningCaseResolver`: parameter alignment and one blank line, all phpcbf-fixable. Lesson recorded for myself: run the whole gate set before every commit — a subset that passed on an earlier tree says nothing about the current one, which is the same shape as every "a check that did not run looks like one that passed" finding in this programme.
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 21:46 UTC
Download the full PDF report from the workflow artifacts.
This was referenced Aug 11, 2026
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 22:03 UTC
Download the full PDF report from the workflow artifacts.
…rned red Reproduced locally against the CANONICAL gate package (ConductionNL/.github @ 57bcb2b), not the adjacent .github checkout.⚠️ Both were invisible to me until CI reported them, for the same reason twice over: when I grepped the PR's check list for a gates cell it was not there yet. `gh pr checks` returns a PARTIAL set — the run grew 23 → 24 → 29 → 32 — so "there is no Hydra Gates job in this repo" was an absence claim read off a list that had not finished being populated. gate-9 semantic-auth (3 findings, all mine) `createTemplate`, `updateTemplate` and `seedDefaults` kept `@NoAdminRequired` while their bodies now require admin — the exact attribute-vs-body mismatch this gate exists to catch, and one I introduced in the previous commit. Fixed with `#[AuthorizedAdminSetting(settings: AdminSettings::class)]`, the idiom already used by KccRoutingController for the same kind of config write, so Nextcloud's middleware enforces admin BEFORE the controller runs. Simply deleting `@NoAdminRequired` would have satisfied gate-9 and broken gate-5, which requires every routed method to declare a posture. The in-body check stays as defence in depth and is what the unit tests drive. gate-19 e2e-coverage (19 findings, NONE of them mine) All ten scenarios I added were already tagged and accepted. The 19 were PRE-EXISTING untagged scenarios in `authz-bypass-fixes/spec.md`, pulled into scope because the gate parses a touched spec file WHOLE. Tagged rather than dodged: each carries a reason naming the specific TEST METHOD that covers it, not just a class — `AdviceServiceAuthorizationTest`, `WOOAssessmentControllerAuthorizationTest`, `ConflictOfInterestServiceTest`. Spot-verified two of the load-bearing ones by reading them: both assert the refusal AND that the underlying service is never reached. One scenario — "A missing conflict service denies rather than skips" — had no coverage at all: every fixture in `MandaatCheckServiceTest` binds a real conflict service. It is COVERED, not waived, by a new test that carries its own positive control (the identical call authorizes when the service is bound, and denies when it is null). I did not move my requirements to a fresh spec file to keep the pre-existing debt out of scope. That would have gone green without closing anything. Local, all green: gate-7 clean · gate-9 clean · gate-16 count=0 · gate-19 PASS · phpcs 0 · phpmd silent on both rulesets · psalm · phpstan · 1803 tests.
…d and which are assumed The coordinator checked my group list against the tree with `grep -w` at cb63aca rather than taking it on trust, and the answer splits: - `beheerders` and `admin` are ATTESTED — both already in use as group names by ProcessMiningController and ParaferingAuditExportController. - `kcc` and `klantcontact` are ASSUMED. Neither appears anywhere in this codebase as a group name; `kcc` occurs only as a feature name, spec slug and CSS class, `klantcontact` only as a ZGW domain term and a spec slug. I chose them. The mechanism was precedented and the names were not, and until now the constant did not say which was which. Someone reading `['kcc', 'klantcontact', 'beheerders', 'admin']` had no way to tell a derived name from an invented one — which is the same class of problem as a docblock asserting a guard that does not exist, just pointed at configuration. Recorded on the constant and in the class docblock, plus a deployment note: the guard denies until the group exists. The error direction is deliberate. A wrong group name means KCC staff get 403 and an operator creates or renames a group in a minute. The alternative was leaving a live HTTP 200 that returned a citizen's phone number and the free-text summary of every previous call to any authenticated account. Fail closed. No behaviour change; comments only.
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 22:22 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-11 22:39 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.
What
Closes the first tranche of the 33 unguarded
#[NoAdminRequired]endpointsfound by the gate-7 fleet re-audit (procest#801) — the ones that touch citizen
personal data.
Live reproduction, two accounts, status codes printed (rig: NC 34.0.0,
procest 0.3.9, openregister 0.2.17-unstable.37). Seeded a case and a
contactmoment for citizen
BSN-999999999aspro-victim; probed aspro-attacker, a second non-admin account with no relationship to any of it:GET /api/kcc/voorblad?burgerId=…unauthenticatedGET /api/kcc/voorblad?burgerId=…as an unrelated accountkccGET /api/contactmomenten?burgerId=…as an unrelated accountPOST /api/kcc/quick-actions/klacht-registrerenas an unrelated accountGET /api/ai/auditwith nocaseIdGET /api/ai/audit?caseId=<victim's case>as an unrelated accountAll existing verification of these endpoints was source reading only. Details
are in
fleet-board/findings/procest.md; no further exploit specifics here.Why gate-7 never saw it
.github#365— the checker acceptsHttp::STATUS_UNAUTHORIZED/401as anauthorisation guard, and 49 of procest's 91 controller files open with the
house-style
if ($user === null) { … 401 }preamble. That is authentication.gate-7 reports
0in all eighteen fleet apps for the same reason..github#372— the layer below does not catch it either:PermissionHandler::hasGroupPermission()returnstruefor an emptyauthorizationblock andenforce_default_closeddefaultsfalse. procest'sprocest_register.jsondeclares 85 schemas, zero with anauthorizationblock, so the controller guard is not the outer of two layers — it is the only
layer.
How
CitizenLookupGuard(new) — role gate for endpoints that resolve a rawburgerId. These have no per-object owner (a KCC handler legitimately answersa call from a citizen they have never handled), so the control is a role, not
a per-case relationship, and
CaseAccessGuarddoes not apply. It follows theidiom already used for the other broad-scope reads in this app
(
ParaferingAuditExportController,AiAuditExportController,ProcessMiningController): a fixed group set plus an admin fallback. Failsclosed in the opposite direction to the bug
CaseAccessGuardwas writtenfor — an absent group grants nothing.
CaseAccessGuard::hasCaseReadAccess()(new) — per-case READ predicate(admin /
assignee/ member ofassignees). Deliberately not delegated toSharing\CaseAccessPolicy::canUserAccessCase(), which returnstruewhenOpenRegister is absent, when the schema is unconfigured, and when the lookup
throws. Three fail-open branches, and its docblock claims an admin check the
class has no
IGroupManagerto perform. Nine call sites rest on it — filedseparately, not changed here.
AiController::auditIndex()—caseIdwas optional and the filter wasbuilt with
array_filter(), so omitting it returned every AI decision recordon the instance.
Also fixed: a fatal that made every endpoint a 500 on the multi-tenant path
TenantMiddleware.php:132called$this->request->setParameter(…).setParameter()exists on neitherOCP\IRequestnorOC\AppFramework\Http\Request, so every request from a user who had atenant died as an unhandled
Error→ HTTP 500 with an HTML page.The branch above returns early for users with no tenant, and single-tenant
installs — CI, every e2e rig, every local install — take that branch. There is
no unit test for this middleware at all. Nothing anywhere reads the three keys
it set, so they are removed rather than re-homed; the tenant-lifecycle
enforcement above is untouched.
It also masked the IDORs while they were being measured: the first probe
round returned 500 for four of six requests, which reads exactly like "the
endpoint is protected".
Tests
ContactMomentControllercases, 9 newCaseAccessGuardcases, 2 newauditIndexcases; every refusal case asserts the collaborator that wouldread or write the data is never reached, and each guard has a positive arm
so it cannot be satisfied by refusing everyone.
ContactMomentControllerturns all 7 red (7 failures / 7 assertions),including both positive arms. Reverted.
lib/; psalm, phpstan,phpmd clean.
composer.lock: phpcsutils 1.2.2 → 1.2.3 (CVE-2026-65954).vendor/here isroot-owned so the bump could not be exercised locally; CI installs fresh.
Not in this PR
The remaining case-scoped endpoints from procest#801 (CaseRelation, Deelzaak,
Berichtenbox, Appointment, Zaakdossier, StatusTransition, EmailTemplate,
BezwaarHearing, Lhs, Advice) follow in the next commits on this branch.
Refs ConductionNL/.github#365, ConductionNL/.github#372, procest#801
Tranche 2 — the case-scoped endpoints (commit 2)
Twenty more endpoints now call
CaseAccessGuard. The ownership field is notinvented:
case.assignee/case.assigneesis the model the guard alreadyencodes and
WOOAssessmentControlleralready enforces.Live reproduction, both arms, hard-flushed opcache
Owner =
pro-victim(=case.assignee). Attacker =pro-attacker2, in thesame group as the owner and differing from it in exactly one attribute: it is
not the assignee.
GET /api/cases/{id}/relationsGET /api/deelzaken/{id}/childrenGET /api/deelzaken/{id}/parentGET /api/case/{id}/available-transitionsGET /api/case/{id}/transition-historyGET /api/dossier/{id}/exportGET /api/appointments?caseId=GET /api/berichtenbox/messages?caseId=POST /api/deelzaken/validatePOST /api/cases/{id}/relationsPOST /api/deelzaken/{id}/unlinkThe owner column is byte-identical across both arms — none of these became
an endpoint that denies everyone.
GET /api/dossier/{caseId}/exportanswers 500 to its own owner in botharms. Pre-existing, untouched here, now unreachable by anyone else. Flagged,
not fixed.
Two measurement traps that nearly produced a wrong table
opcache.revalidate_freq=60. Aftergit checkout <ref> -- lib/, eachfile revalidates on its own 60-second clock, so one request can execute one
controller at the new revision and another at the old one. Three runs of the
same 11 endpoints gave three different, internally consistent tables. Only
docker restartbetween arms is reliable —opcache_reset()viadocker exec php -rresets nothing, becauseopcache.enable_cliis Off, andexits 0.
the KCC fix required). Six of eleven endpoints then read as "already
protected" because procest's SaaS middleware refused the group-less account —
a 403 that looks exactly like the IDOR being absent.
Checked before trusting the guard
CaseAccessGuard::loadCase()ends incatch (Throwable) { return null; }—pipelinq#805's exact shape, where a fatal named-argument call becomes "deny
everyone".
ObjectService::find()declaresid,registerandschema, sothe call cannot raise; the unchanged owner column confirms it live.
🔶 One product decision that needs sign-off (see the deployment banner at the top)
For the case-scoped endpoints the ownership field already existed. The
citizen-lookup guard is different — the subject is a citizen, not a case, and
a KCC handler legitimately looks up someone they have never handled, so no
per-object predicate exists. I introduced one: membership of
kcc/klantcontact/beheerders, plus admin, copying theALLOWED_GROUPSidiom already used by
ParaferingAuditExportController,AiAuditExportControllerandProcessMiningController.The shape is the app's own; two of the four group names are mine. Checked
with
grep -w:beheerdersandadminare attested elsewhere in this app,kccandklantcontactare not attested anywhere as group names.Shipped rather than leaving citizen personal data readable by every account, and
admins keep working so no deployment loses its administrator — but this is a
security control nobody has agreed. Please ratify or rename the two invented
names. The assumption is stated on
CitizenLookupGuard::ALLOWED_GROUPSitselfso nobody reading the constant has to guess which names were derived and which
were invented.
Still open from procest#801
EmailController::templatesandEmailTemplateController::listTemplates/variables(case-type config reads, weighted down by the audit as crossing noper-user boundary), and
TemplateController::activate— not an IDOR, but anyauthenticated user can create caseTypes and statusTypes wholesale. Logged
separately rather than folded in here.
Commit 3 — PHPMD, and a retraction
CI caught what my local run missed: I ran phpmd at the tranche-1 checkpoint and
not again before committing tranche 2, and my three
getCaseIdFor*()resolverswere near-identical copies. Extracted to one
OwningCaseResolver, which closesCyclomaticComplexity 10 ×2, NPathComplexity 288 ×2 and ExcessiveClassComplexity
53 by deleting the duplication, not by moving a threshold. The upload guard
moved to
DossierUploadHandler::hasCaseUploadAccess()— the collaborator thatperforms the write — which also takes
ZaakdossierControllerback under thecoupling threshold my added dependency had pushed it over. phpmd is now silent
on both rulesets; no baseline, threshold or test was weakened.
🔴 Retraction:
POST /api/bezwaar/hearings/{sessionId}/attendancewas never a live IDORFound while live-verifying the resolver's positive path. With a real
hearingSession seeded, the legitimate owner gets
400 {"error":"Hearing session not found"}:ObjectService::find()returns?ObjectEntity, never an array — the throw isunconditional. The endpoint has never worked and never leaked anything.
I only caught it because I probed the owner as well as the attacker: the
attacker arm returned a clean 403 and looked exactly like a closed
vulnerability, while the owner's refusal came back in the wrong dialect
(
400 … not foundwhere the guard's refusal is403 Not authorized).Deliberately not repaired — a legally-sensitive Awb art. 7:7 audit path
whose correct post-repair behaviour I cannot validate here. The guard is in
place first, so repairing the lookup cannot create a live IDOR.
Honest accounting — this PR does not close "33 live IDORs"
Guards were added at 33 call sites. What the live probes demonstrate:
409)dossier/exportis 500 to its own owner in both arms;deelzaken/parent404/404 because the fixture case has no parent)dossier/exportreturning 500 to its owner is the same shape as the deadhearing endpoint. I did not chase it, so it is not counted as a demonstrated
IDOR either way — flagged for follow-up.
Commit 5 — the two gate cells my own change turned red
Reproduced locally against the canonical gate package
(
ConductionNL/.github@57bcb2b), not the adjacent.githubcheckout.grepped this PR's check list for a gates cell it was not there yet.
gh pr checksreturns a partial set — the run grew 23 → 24 → 29 → 32 — so"there is no Hydra Gates job in this repo" was an absence claim read off a list
that had not finished being populated.
gate-9 semantic-auth — 3 findings, all mine
createTemplate/updateTemplate/seedDefaultskept@NoAdminRequiredwhile their bodies now require admin: the exact attribute-vs-body mismatch this
gate exists to catch, introduced by me in commit 2.
Fixed with
#[AuthorizedAdminSetting(settings: AdminSettings::class)]— theidiom
KccRoutingControlleralready uses for the same kind of config write — soNextcloud's middleware enforces admin before the controller runs. Simply
deleting
@NoAdminRequiredwould have satisfied gate-9 and broken gate-5,which requires every routed method to declare a posture. The in-body check stays
as defence in depth and is what the unit tests drive.
gate-19 e2e-coverage — 19 findings, none of them mine
All ten scenarios I added were already tagged and accepted. The 19 were
pre-existing untagged scenarios in
authz-bypass-fixes/spec.md, pulled inbecause the gate parses a touched spec file whole.
Tagged, not dodged. The tempting move was to put my new requirements in a
fresh spec file — instantly green, debt untouched and invisible again. I didn't.
Each reason names the specific test method, not just a class:
AdviceServiceAuthorizationTest,WOOAssessmentControllerAuthorizationTest,ConflictOfInterestServiceTest. I read the two load-bearing ones to confirmthey assert the refusal and
expects($this->never())on the service beneath.One scenario had no coverage at all — "A missing conflict service denies
rather than skips"; every fixture in
MandaatCheckServiceTestbinds a realservice. That one is covered by a new test, not waived, and the test carries
its own positive control: the identical call authorizes with the service bound
and denies when it is null.
Local verification, all green
gate-7 clean · gate-9 clean · gate-16
count=0· gate-19 PASS · phpcs 0 ·phpmd silent on both rulesets · psalm · phpstan · 1803 tests.