fix(security): close four authorisation holes the full-tree Hydra gates surfaced - #2342
fix(security): close four authorisation holes the full-tree Hydra gates surfaced#2342rubenvdlinde wants to merge 3 commits into
Conversation
Re-derived openregister's Hydra Gates against the FULL TREE (the enablement
PRs were diff-scoped over three CI files, so 58 gates 'passed' having read no
lib/). 19 gates fail on the whole tree; gates 6/7/8/9 — the ADR-005
authorisation family — account for 27 findings. Triaged each by reading the
call path. Four are real.
1. FlowController::state() was an unguarded cross-organisation read.
`GET /api/flow/{flowId}/state` is @NoAdminRequired and handed the
client-supplied uuid straight to FlowStateMapper::findByFlow(), which
applies no organisation scoping. Any authenticated user could read any
other tenant's flow state by uuid. It was also the one method in the class
that broke the invariant its own file header states. Now resolved through
FlowService::find() first, so a flow the caller may not see 404s exactly
like one that does not exist.
2. FederatedConfigController::bundle() was publish() without publish()'s gate.
Same serialise() call, same bytes, no canPublish check — so anyone the
publish gate refused could ask for the export directly. Gated now.
Underneath it, two serialisers were reading past the caller's tenant:
GenericObjectShareableConfigType::serialise() passed _rbac:false and
_multitenancy:false (an ENGINE escape hatch, on a path a REQUEST reaches),
and FlowShareableConfigType::serialise() went to FlowMapper::findByUuid()
directly. Both now read scoped.
3. An object-scope federated share reached further than it granted.
buildScopeConfig() pins filters['uuid'] on the collection endpoint, but
object()/updateObject()/deleteObject() took {id} from the URL and never
compared it to the grant — so a token for ONE object read, overwrote or
deleted ANY object in the same register/schema, confidential ones included
(applyShareVisibility() skips the confidentiality filter for object scope).
scopeCoversObject() closes it; other scopes are untouched.
4. A bulk row whose schema failed to load was written with no RBAC check.
resolveSafeguardSchema() swallowed every Throwable into a bare null, which
is also what a legitimate mixed-schema batch looks like — and that branch
passes the row through. The write still went ahead, because the fast path
re-resolves the schema downstream. 'Could not resolve' now refuses instead
of skipping. resolveSafeguardRegister() is deleted rather than fixed: its
result was assigned and never read.
16 tests, each verified to FAIL with lib/ reverted and pass with it restored.
Full unit suite: 16023 tests, same 29 errors / 2 failures as the untouched
baseline (all pre-existing, Doriath fixtures + unrelated). phpcs, phpmd,
psalm, phpstan clean on every changed file. No suppression, baseline entry or
weakened assertion anywhere.
gate-16 (spec-coverage) is diff-scoped and correctly caught that the four changed methods carried no @SPEC. The federation serving surface had no spec at all, so rather than tag it at something adjacent this writes the change the fixes actually implement — four requirements with scenarios for both the refusal and its positive control.
Pre-existing finding in the file this change already touches. setRegister()/ setSchema() resolve through the mappers, so a share naming a deleted register throws; the caller wraps it, but nothing said so.
Full triage — every gate-6/7/8/9 finding, with the call pathMeasured full-tree at gate-6 orphan-auth — 1 finding, 0 real
gate-7 no-admin-idor — 18 findings, 2 real
gate-8 unsafe-auth-resolver — 2 findings, 1 real (gate now PASSES)
gate-9 semantic-auth — 6 findings, 0 real as stated (but the triage found a real defect underneath)All six are However, reading those six call paths is how finding 3 in the PR body turned up: Counter-example held in mindThe dead |
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 173/173 | |||
| npm | ✅ | ✅ 713/713 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-05 13:43 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 173/173 | |||
| npm | ✅ | ✅ 713/713 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-05 14:00 UTC
Download the full PDF report from the workflow artifacts.
Why
Enabling the Hydra gates here (#2335) produced a green tick that certified nothing. The gates are diff-scoped, and the enablement PR touched three CI files:
58 gates "passed" in about a second having read no
lib/, nosrc/, noroutes.php.Re-run against the full tree, with
ajvresolvable as CI has it, under an isolated/tmp: 19 gates fail atf1614ce67. Gates 6/7/8/9 — the ADR-005 authorisation family — account for 27 findings. I read the call path for every one. Four are real. All four are the same shape: an authorisation decision one path makes and a neighbouring path does not.The four
1.
FlowController::state()was an unguarded cross-organisation read.GET /api/flow/{flowId}/stateis@NoAdminRequiredand handed the client-supplied uuid straight toFlowStateMapper::findByFlow(), which applies no organisation scoping at all. Any authenticated user could read any other tenant's flow state — arbitrary data written by flow nodes: slot holders, external identifiers, run bookkeeping — by naming its uuid. It was also the one method in the class that broke the invariant stated in the class's own file header: "Every CRUD method goes throughFlowService, neverFlowMapper." Now resolved throughFlowService::find()first, so a flow the caller may not see 404s exactly like one that does not exist.2.
FederatedConfigController::bundle()waspublish()withoutpublish()'s gate.Same
serialise()call, same bytes, nocanPublish()check — so anyone the publish gate refused could ask for the export directly. Underneath it, two serialisers read past the caller's tenant:GenericObjectShareableConfigType::serialise()passed_rbac: false, _multitenancy: false(engine escape hatches, on a path a request reaches), andFlowShareableConfigType::serialise()went toFlowMapper::findByUuid()directly. All three fixed.3. An object-scope federated share reached further than it granted.
buildScopeConfig()pinsfilters['uuid']on the collection endpoint, butobject()/updateObject()/deleteObject()took{id}from the URL and never compared it to the grant. A token for one object read, overwrote or deleted any object in the same register/schema — confidential ones included, becauseapplyShareVisibility()deliberately skips the confidentiality filter for object scope. The item path was strictly wider than the list path that guards it.scopeCoversObject()closes it; register/schema/query scopes are untouched. The refusal is a 404, not a 403, so a scoped token is not an enumeration oracle.4. A bulk row whose schema failed to load was written with no RBAC check.
resolveSafeguardSchema()swallowed every\Throwableinto a barenull— which is also what a legitimate mixed-schema batch looks like, and that branch passes the row through unchecked. The write still went ahead, because the single-schema fast path re-resolves$schemadownstream and never consults the safeguard's opinion. "Could not resolve" now refuses.resolveSafeguardRegister()is deleted rather than fixed: its result was assigned to$defaultRegisterand never read.Evidence
16 new tests across 5 files. Each was run with
lib/reverted and confirmed to fail, for the right reason — not merely to error:staterefuses a foreign flowFlowStateMapper::findByFlow('someone-elses-flow')was calledObjectServiceserialise()was reachedfindByUuidon the unscoped mapper, called 1 timeEvery refusal test is paired with a positive control, so none of them is satisfiable by an endpoint that refuses everything.
phpcs,phpmd,psalm,phpstan: clean on every changed file..skip, no weakened assertion, no@spec excludeanywhere. Nothing added tophpmd.baseline.xml.Also fixes one pre-existing gate-49 finding in a file this change already touches (
setServeContext()had no@throws).Gates, before and after
Diff-scoped as CI runs it, two gates remain red — both false positives, both reported upstream rather than worked around: ConductionNL/.github#160.
FlowController::state()— the method this PR fixed. Fix a bug with uuid's that have integer-values #149 taught it to follow delegation, but only through an explicit deny (401/403/isAdmin/require*). OpenRegister's tenancy guard deliberately throwsDoesNotExistExceptioninstead, because a 403 would leak the existence of another tenant's object. The gate's verdict did not change when the defect did; it would go green if I made the code leak. 17 of its 18 full-tree findings are this pattern.#[PublicPage]federation endpoints. They are public because the caller is another server; the bearer share token is the credential. Its advice — "remove#[PublicPage]or remove body auth check" — breaks federation or opens it. Pre-existing ondevelopment.gate-8now passes (was 2).gate-16caught that the changed methods carried no@spec— the federation serving surface had no spec at all, so this writes the change it implements rather than tagging something adjacent.The upstream issue also reports a third defect found on the way: the fixed
/tmp/hydra-gate-*.logpaths make two parallel runs on one host contaminate each other's verdicts. Same sha, minutes apart, gate-39 flipped PASS↔FAIL and gate-7 read 18 vs 25. That is why the previously-circulated "24 failures" figure is wrong — it was measured under a shared/tmp. Under a private one the run is byte-identical across repeats.Needs a product decision (not changed here)
FederationController::createShareis#[NoAdminRequired]with no group gate — any organisation member can mint an outgoing federated share exposing that org's register data to an external instance. The organisation is server-stamped, so it is not an IDOR, but it is a broader grant thanfederated_config_publish_groupsgives the config-sharing path. Deliberate asymmetry, or an oversight?