Skip to content

[security] GET /api/settings/:namespace returns encrypted setting values as plaintext — no redaction at the REST read boundary #7522

Description

@huangyiirene

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):

  1. packages/services/service-settings/src/settings-service.tsmaterialiseRow() (~:1870) sees row.encrypted, dereferences the sec_ handle through secretStore, and calls cryptoProvider.decrypt(...), returning plaintext.
  2. 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.
  3. getNamespace() (~:1118) loops this.get(namespace, key, ctx) over every key and returns { manifest, values } with no redaction step.
  4. 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.

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions