Skip to content

fix(security): fail closed on unconfigured register/schema bindings (gate-50, gate-54, gate-1) - #413

Closed
rubenvdlinde wants to merge 4 commits into
developmentfrom
dd-gates/backend
Closed

fix(security): fail closed on unconfigured register/schema bindings (gate-50, gate-54, gate-1)#413
rubenvdlinde wants to merge 4 commits into
developmentfrom
dd-gates/backend

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

⚠️ DRAFT — not merge-ready. 18 unit tests are red. See "What is left" below.

Gates closed (package 7f5f9e78, = .github main + PR #286)

gate before after
1 spdx-headers FAIL — 1 missing @copyright PASS
50 security-config-fail-mode FAIL — 29 unsafe reads PASS
54 relation-dialect FAIL — 2 findings PASS (needs ConductionNL/.github#286)

Can-fail proof for gate-50: removing one guard produced exactly FAIL — 2, naming lib/Service/FinancialExtractionService.php:259 and :260 — the two reads whose guard was removed, and nothing else.

⚠️ gate-50 cannot see a read whose app-id argument is a plain variable, so its zero is not proof the whole class is gone. All reads in the touched files use a literal 'docudesk', and the five accessors keep it that way — the reads stayed visible rather than being hidden behind a computed key.

The defect

Every gate-50 finding was one shape: a register/schema binding read with an empty-string default, passed straight into saveObject()/find(). An administrator sets these in the admin UI and nothing auto-provisions them, so an unbound instance wrote to register '' and schema '' and carried on — for signing, the audit trail behind an eIDAS-level signature.

SettingsService now owns the reads with one guarded accessor per binding, returning null rather than throwing (so it needs no new import — see the coupling note). Each consumer decides what unconfigured means: SigningService throws RegisterNotConfiguredException (already handled by SigningController), GlAccountSuggestionService lets it propagate to controllers that catch Exception, PortalSigningReceiverController denies.

Two sites gate-50 did NOT flag are fixed toogetRequest() and the decline path sat inside the gate's 10-line window thanks to an adjacent null-check, but carry the same defect: find() against register '' returns null and both report "not found", so an unconfigured instance answered 404 for every signing request that does exist.

SigningVerificationService::getSigningSecret() now returns ?string. Its single caller already failed closed 85 lines away, so there was no live defect — but '' is a valid HMAC key, and a caller that forgot the check would verify signatures against a publicly derivable key and call them genuine.

PHPMD: four metrics went over, no threshold changed

composer phpmd exit 0. SigningService coupling was resolved by dropping IAppConfig entirely — its three remaining reads duplicated the keys and the defaults ('30', 'SES', 'native') that SettingsService::loadFeatureToggles() already owns, two sources of truth free to drift.

lint / phpcs / psalm / phpstan / phpmd all exit 0.

What is left (why this is a draft)

vendor/bin/phpunit: 13 errors + 5 failures, all mine. Three suites build SettingsService mocks without stubbing the new resolve*Binding() accessors, so they return null and the fail-closed guard fires — which is the guard working, not a code defect. SigningServiceTest is already fixed this way (33/33 green). Remaining: GlAccountSuggestionServiceTest builds three mocks (lines 78, 414, 469) and only two are stubbed; PortalSigningReceiverControllerTest and FinancialExtractionServiceTest each have an unstubbed instance. Mechanical, but unfinished and therefore not claimed as done.

gate-1 (spdx-headers) reported one missing @copyright fleet-wide for this
repo, on lib/Controller/PreferencesController.php. Every other controller
in lib/Controller/ carries the full Conduction tag block plus the two SPDX
identifier lines; this file was written with @author/@license only.

Adds @copyright and the SPDX-FileCopyrightText / SPDX-License-Identifier
lines, matching PolicyController.php verbatim in shape.
gate-54 (relation-dialect) reported:

  correspondence.caseReference — $ref 'case' does not resolve to a schema key
  generatedDocument.zaakId     — $ref 'case' does not resolve to a schema key

Both are correct. There is no `case` schema in DocuDesk's register set, and
there cannot be: both properties reference a Zaak that lives in PROCEST's
register, which is why both already carry `x-external-register: procest`.
OpenRegister resolves `$ref` within one register set, so these two `$ref`s
named a schema the engine could never reach — a relation that only looked like
one. Nothing consumed them as relations either: PdfService reads
`caseReference` as `(string) ($options['caseReference'] ?? '')` and
GeneratedDocumentLogger passes `zaakId` straight through.

Dropping the `$ref` leaves the correct dialect — a bare `format: uuid`
identifier plus `x-external-register` recording where the target lives.

The descriptions were Dutch prose ("UUID verwijzing naar de bron zaak/case in
Procest"); re-authored in English per the fleet rule, and expanded to say why
there is no `$ref`. `Zaak` is a standardised ZGW term and is kept as such —
only the surrounding prose changed, and the property name `zaakId` is
untouched.

NOTE: this alone does not close gate-54. The gate had no concept of a
cross-app reference, so removing the `$ref` merely flipped the finding to the
opposite arm ("relation-shaped property ... lacks canonical $ref"). Both arms
failed — an unclosable gate. Fixed in ConductionNL/.github#286; with that
package this file measures gate-54 PASS.
…ured

gate-50 (security-config-fail-mode): 29 findings -> 0.

Every finding was the same shape: a register/schema binding read with an
empty-string default and passed straight into saveObject()/find(). An
administrator sets these in the admin settings UI and nothing auto-provisions
them, so on an unbound instance DocuDesk wrote to register '' and schema ''
and carried on. For signing that means the audit trail behind an eIDAS-level
signature; for financial extraction the supplier, IBAN, KvK, VAT id and
amounts read off an invoice. Nothing surfaced.

SettingsService now owns the reads, one guarded accessor per binding
(resolveSigningRequestBinding, resolveSignerRecordBinding,
resolveFinancialExtractionBinding, resolveGlAccountBookingBinding,
resolveGlAccountMappingRuleBinding). They return null rather than throwing so
that SettingsService needs no new import — see the coupling note below — and
each consumer decides what "unconfigured" means for it:

* SigningService throws RegisterNotConfiguredException, which
  SigningController already catches to render a calm "not configured" empty
  state. Two further sites gate-50 did NOT flag are converted too: getRequest()
  and the decline path sat inside the gate's 10-line window because of an
  adjacent null-check, but carry the same defect — find() against register ''
  returns null, and both report "not found", so an unconfigured instance
  answered 404 for every signing request that does exist.
* GlAccountSuggestionService lets it propagate; both its controllers catch
  Exception, so it becomes an honest error instead of a wrong answer. "No
  booking history" and "not configured" produced the same empty array, and the
  ranker treats the first as a legitimate cold start — an unbound instance
  silently ranked every supplier as brand new.
* PortalSigningReceiverController denies. This is the anti-IDOR boundary
  (REQ-DDPSA-004) and register/schema are two of the four filters scoping the
  lookup; passing '' made the boundary's correctness depend on OpenRegister
  choosing to match nothing for an empty filter, an assumption this code never
  stated and does not control. The existing catch collapses it to the same null
  the wrong-email and wrong-request cases return, so no new signal is exposed.

SigningVerificationService::getSigningSecret() now returns ?string. Its single
caller already failed closed correctly, 85 lines away and outside the gate's
window, so there was no live defect — but '' is a VALID HMAC key, and a future
caller that forgot the check would verify signatures against a publicly
derivable key and report them genuine. null cannot reach hash_hmac() by
accident.

PHPMD: the guards pushed four metrics over threshold, and no threshold was
changed. SigningService's coupling was resolved by dropping IAppConfig
entirely — its three remaining reads (signing_request_expiry_days,
signing_default_level, signing_provider) duplicated both the keys AND the
defaults that SettingsService::loadFeatureToggles() already owns, two sources
of truth free to drift. SettingsService stays at its previous coupling because
the accessors return null instead of importing the exception type.
GlAccountSuggestionService's class complexity is back under threshold because
the guards moved out of it.

SigningServiceTest stubs the two binding resolvers. That is not incidental: an
unstubbed mock returns null, which is exactly what the fail-closed guard is
there to catch, and 24 of its tests failed until the configured path was
stated explicitly.
Partial. FinancialExtractionServiceTest, GlAccountSuggestionServiceTest and
PortalSigningReceiverControllerTest construct SettingsService mocks that do not
stub the new resolve*Binding() accessors, so they return null and the
fail-closed guard fires. GlAccountSuggestionServiceTest builds three separate
mocks (lines 78, 414, 469) and only two are stubbed; the Portal suite has a
second mock instance still unstubbed. 18 tests remain red for this reason.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 024c9e5

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
test
test-unit
check-manifest
test-l10n
composer ✅ 114/114
npm ✅ 646/646
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-09 15:45 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Superseded by the rebased branch — same work on top of #409/#410/#412, with the unit suite green and composer check:strict passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant