Extracted from the QA run #7514 (settings-hub-roundtrip, framework a86db175). The run held back the step-by-step recipe pending a maintainer disclosure decision; the defect and its root cause were already public in that report, and this card carries the same level of detail — no new disclosure. The reproduction recipe stays withheld here too.
Symptom
Storage of encrypted settings is correct: sys_setting.value is null, encrypted: true, value_enc: 'sec_…', and sys_secret holds aes-256-gcm ciphertext. The leak is on the way out:
GET /api/settings/:namespace returns the secret's plaintext in values.<key>.value, and repeats that same plaintext inside every cascadeChain entry.
Both specifier flavours are affected — type: 'password' and the explicit encrypted: true.
The endpoint requires setup.access (anonymous callers get 403), so this is defense-in-depth, not privilege escalation. It is still a real exposure: any operator or integration holding setup access — and any log, proxy, browser cache, or HAR capture on that response path — receives the cleartext of every secret in the namespace, when the whole point of value_enc + sys_secret is that those values never materialise outside the crypto boundary.
Root cause
Verified against origin/main at the time of filing (not merely restated from the run):
packages/services/service-settings/src/settings-service.ts — materialiseRow() (~:1870) sees row.encrypted, dereferences the sec_ handle through secretStore, and calls cryptoProvider.decrypt(...), returning plaintext.
- The same file's
get() builds cascadeChain by calling materialiseRow() per scope (~:1068 global, ~:1082 tenant, ~:1094 user), so the plaintext is copied into each chain entry as well as the resolved value.
getNamespace() (~:1118) loops this.get(namespace, key, ctx) over every key and returns { manifest, values } with no redaction step.
packages/services/service-settings/src/settings-routes.ts (~:75-87) — the GET ${base}/:namespace handler calls service.getNamespace(ns, ctx) and hands the payload straight to sendOk. There is no redaction anywhere on this path.
Where the fix belongs — and where it must NOT go
The missing boundary is the REST read, not the service.
The service-layer decryption is deliberate and load-bearing: snapshotOf() / createClient() (~:1190-1200) consume payload.values[k].value to hand plugins their real secret values, and settings-service.test.ts pins that round-trip on purpose. Redacting inside materialiseRow() or getNamespace() would break every legitimate in-process consumer.
⛔ So do not "fix" this by making the service stop decrypting. The REST response is the only surface that should never carry the cleartext.
Acceptance criteria
GET /api/settings/:namespace never emits the plaintext of a key whose specifier is encrypted: true or type: 'password' — neither in values.<key>.value nor anywhere in cascadeChain.
- The redaction is presence-preserving: a caller must still be able to tell that a value is set vs unset (so the console can render "configured" state and the existing env-lock affordances keep working). Pick the marker shape that matches whatever this repo already uses for masked reads — check the encrypted-field read-mask convention that
records-forms' encrypted-field-behavior item pins, and follow it rather than inventing a new sentinel.
- The existing write path keeps working, including the no-op echoed-mask case: PUTting back a redacted marker must not overwrite the stored secret with the marker's literal text. Assert this explicitly — it is the classic second bug introduced by a redaction fix.
source, locked, and the 409 SETTINGS_LOCKED env-lock behaviour are unchanged (the run verified these are currently correct).
- In-process consumers (
createClient / snapshotOf) still receive real plaintext — add or keep a test proving the service layer is untouched.
- A regression test at the route level asserting the response body contains no ciphertext-backed cleartext for both specifier flavours.
Source
Extracted from the QA run #7514 (framework a86db175, vendored console 09987b68). Root cause re-verified against origin/main before filing.
Extracted from the QA run #7514 (
settings-hub-roundtrip, frameworka86db175). The run held back the step-by-step recipe pending a maintainer disclosure decision; the defect and its root cause were already public in that report, and this card carries the same level of detail — no new disclosure. The reproduction recipe stays withheld here too.Symptom
Storage of encrypted settings is correct:
sys_setting.valueis null,encrypted: true,value_enc: 'sec_…', andsys_secretholds aes-256-gcm ciphertext. The leak is on the way out:GET /api/settings/:namespacereturns the secret's plaintext invalues.<key>.value, and repeats that same plaintext inside everycascadeChainentry.Both specifier flavours are affected —
type: 'password'and the explicitencrypted: true.The endpoint requires
setup.access(anonymous callers get 403), so this is defense-in-depth, not privilege escalation. It is still a real exposure: any operator or integration holding setup access — and any log, proxy, browser cache, or HAR capture on that response path — receives the cleartext of every secret in the namespace, when the whole point ofvalue_enc+sys_secretis that those values never materialise outside the crypto boundary.Root cause
Verified against
origin/mainat the time of filing (not merely restated from the run):packages/services/service-settings/src/settings-service.ts—materialiseRow()(~:1870) seesrow.encrypted, dereferences thesec_handle throughsecretStore, and callscryptoProvider.decrypt(...), returning plaintext.get()buildscascadeChainby callingmaterialiseRow()per scope (~:1068 global, ~:1082 tenant, ~:1094 user), so the plaintext is copied into each chain entry as well as the resolved value.getNamespace()(~:1118) loopsthis.get(namespace, key, ctx)over every key and returns{ manifest, values }with no redaction step.packages/services/service-settings/src/settings-routes.ts(~:75-87) — theGET ${base}/:namespacehandler callsservice.getNamespace(ns, ctx)and hands the payload straight tosendOk. There is no redaction anywhere on this path.Where the fix belongs — and where it must NOT go
The missing boundary is the REST read, not the service.
The service-layer decryption is deliberate and load-bearing:
snapshotOf()/createClient()(~:1190-1200) consumepayload.values[k].valueto hand plugins their real secret values, andsettings-service.test.tspins that round-trip on purpose. Redacting insidematerialiseRow()orgetNamespace()would break every legitimate in-process consumer.⛔ So do not "fix" this by making the service stop decrypting. The REST response is the only surface that should never carry the cleartext.
Acceptance criteria
GET /api/settings/:namespacenever emits the plaintext of a key whose specifier isencrypted: trueortype: 'password'— neither invalues.<key>.valuenor anywhere incascadeChain.records-forms'encrypted-field-behavioritem pins, and follow it rather than inventing a new sentinel.source,locked, and the409 SETTINGS_LOCKEDenv-lock behaviour are unchanged (the run verified these are currently correct).createClient/snapshotOf) still receive real plaintext — add or keep a test proving the service layer is untouched.Source
Extracted from the QA run #7514 (framework
a86db175, vendored console09987b68). Root cause re-verified againstorigin/mainbefore filing.