A store install creates, and can never replace a local object - #1687
Conversation
Found reviewing my own merged code from #1677, and it is a hole in the boundary that PR was largely about. OpenRegister resolves the object it writes FROM THE PAYLOAD: `extractUuidAndNormalizeObject()` reads `$object['@self']['id'] ?? $object['id']` and treats a match as the uuid to UPDATE. So a store item whose component carried the uuid of this municipality's live case type would replace it — and `saveObject` is PUT-semantic, so keys the payload omits are nulled rather than left alone. The case type would not merely change, it would be gutted. THE SCHEMA ALLOWLIST DOES NOT COVER THIS. It governs which schema a component may write, never whether the write creates or replaces. A component naming `caseType` — entirely legitimate, exactly what the store is for — is the attack. I wrote that allowlist and called it the boundary; it was half of one. Identity is not a remote registry's to supply. `id`, `uuid` and `@self` are stripped before the write, so an installed component is always a NEW local object. If install ever needs to be idempotent it must key on something dossiq controls. The test carries a negative control: removing the strip makes it fail.
|
Swept the defect class rather than just this instance. openbuild, the other
dossiq's install was the outlier: it passed the component's object array straight to Worth stating for whoever writes the third consumer, since ADR-080 Decision 3 deliberately leaves install to each app: the engine gives you |
Quality Report — ConductionNL/dossiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 106/106 | |||
| npm | ✅ | ✅ 540/540 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-02 19:21 UTC
Download the full PDF report from the workflow artifacts.
Found reviewing my own merged code from #1677. It is a hole in the boundary that PR was largely about, so it is worth stating plainly rather than filing quietly.
The defect
OpenRegister resolves the object it writes from the payload itself.
ObjectService::extractUuidAndNormalizeObject()reads:and treats a match as the uuid to UPDATE.
saveObjectis PUT-semantic, so keys the payload omits are nulled rather than left alone.So a store item whose component carried the uuid of this municipality's live case type would not merely change it — it would gut it. Through a button labelled Install.
Why the allowlist did not catch it
INSTALLABLE_SLUGSgoverns which schema a component may write. It says nothing about whether the write creates or replaces. A component namingcaseTypeis entirely legitimate — it is exactly what the store exists for — and that is what makes it the attack.I wrote that allowlist and called it the security boundary. It was half of one.
The fix
id,uuidand@selfare stripped before the write, so an installed component is always a new local object. Identity is not a remote registry's to supply.If install ever needs to be idempotent, it must key on something dossiq controls rather than on a remote id.
Evidence
The test carries a negative control: removing the strip makes
testAnInstalledComponentCannotOverwriteAnExistingObjectfail. It asserts@selftoo, not justid, because@self.idis the onesaveObjectreads first.2768 PHPUnit tests, phpstan clean.
🤖 Generated with Claude Code