fix(auth): read:secrets — withhold app secret values from read-only principals (F03) - #523
Merged
Merged
Conversation
…rincipals (F03) Every unmatched GET names no capability, which is the documented operator model: if you hold a key to this host, you may read it (`authorizeRequest`). But the env-bearing reads carried each app's database password, API token and encryption secret in plaintext, so the read-only capability set an authenticated non-admin OIDC user receives was in practice full credential access to every installed app — and `HOLA_OIDC_ADMIN_GROUP` is fail-closed, so on a host with no admin group configured that is EVERY dashboard user. Reading an app's configuration and reading its credentials are now separate grants. `read:secrets` is held by `*`/admin and is deliberately absent from `READONLY_CAPABILITIES`; the secret VALUES are withheld from any principal lacking it. Enforced where the response is shaped, not as a route capability: making `/config` demand `read:secrets` would take the whole configuration view away from read-only users, who have a legitimate reason to see which variables an app is configured with. What they lose is only the values that were never theirs. Three surfaces go through `canReadSecrets(req)`: - GET /api/deployments/:id/config - GET /api/drafts/:id (same rows, including generated secrets) - GET /api/settings (host-wide systemEnv: SMTP_PASSWORD et al) `redactSecretEnvValues` blanks the value and sets `valueRedacted: true`. The marker is not decoration: a withheld secret and a genuinely empty one both carry `value: ''`, and the honest rendering of the two is not the same. `valueRedacted` is also a WRITE-side instruction, and that is what makes redacting an editable surface safe. The config read and the config write are the same rows, so a client saving the form it was given sends `value: ''` back for every secret it was not shown; without the rule the confidentiality fix would have introduced a secret-wiping write. The merge reads the flag as "no new value supplied for this key" and keeps the stored value — `hardenAppEnv`/`mergeAppEnv` for deployment/draft env, and the new `restoreWithheldEnvValues` for the full-replace `systemEnv` PATCH. An explicit `removeEnvKeys` entry still wins over it: withholding a value on read must not make a secret undeletable. The flag is stripped on every write path, so a forged one cannot make a stored row read as withheld. The check is `principalHasCapability`, decided from the principal alone and deliberately NOT routed through `AuthService.hasCapability`: `MockAuthService` returns true unconditionally and `RealAuthService` short-circuits to true whenever auth is disabled. Both are right for a route gate and both are wrong for shaping a response — they would make the rule a no-op in exactly the configurations where a test can observe it. When auth is disabled the middleware substitutes a wildcard system principal, so a single-operator host still sees everything, by holding `*` rather than by the check being skipped. No principal resolved fails closed. Web: a withheld value renders as `•••••••• hidden` with no reveal control (there is nothing behind that eye), is relaxed to optional for client-side validation so a `required` secret nobody may read cannot block every save, and drops its marker the moment the operator types a replacement. Tests +33 (server 1426 → 1459, web 379 → 383). The route-level half is driven through `route()` rather than `fetch`, because the auth middleware substitutes a wildcard principal whenever auth is disabled — which the test environment always is — so an HTTP-level request could never present a read-only principal, and a test that cannot present one cannot observe this rule at all. Revert-proof measured: with the two redaction calls removed, 6 of 12 route tests fail (the 6 that pass are the admin/non-secret cases, which must pass on both sides); with the three web changes reverted, all 4 new web tests fail. Residual, not in scope here: the tracker's own suggestion of restricting the complete configuration to administrators outright would also close this, and would additionally stop a stolen admin session from being an inventory of every app credential. That is a product decision about the dashboard's purpose, not a fix for this finding, and it is not made here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes F03 on the Security & Code Quality Review Remediation Tracker — "Read-only dashboard users can retrieve application secrets."
Reading an app's configuration and reading its credentials are now separate grants.
read:secretsis held by*/admin and is deliberately absent fromREADONLY_CAPABILITIES; secret values are withheld from any principal lacking it.Why
Every unmatched GET names no capability — that is the documented operator model (
authorizeRequest: "if you hold a key to this host, you may read it"). But the env-bearing reads carried each app's database password, API token and encryption secret in plaintext, so the read-only set an authenticated non-admin OIDC user receives was in practice full credential access to every installed app.Worse than the reviewer's framing:
HOLA_OIDC_ADMIN_GROUPis fail-closed, so on a host where no admin group is configured, every authenticated dashboard user is read-only — and every one of them could read every app secret.Design
Enforced where the response is shaped, not as a route capability. Making
/configdemandread:secretswould take the whole configuration view away from read-only users, who have a legitimate reason to see which variables an app is configured with. What they lose is only the values that were never theirs.Three surfaces go through
canReadSecrets(req):GET /api/deployments/:id/configappEnvGET /api/drafts/:idGET /api/settingssystemEnv(SMTP_PASSWORDet al)redactSecretEnvValuesblanks the value and setsvalueRedacted: true. The marker is not decoration: a withheld secret and a genuinely empty one both carryvalue: '', and the honest rendering of the two is not the same.valueRedactedis also a write-side instructionThis is what makes redacting an editable surface safe. The config read and the config write are the same rows, so a client saving the form it was given sends
value: ''back for every secret it was not shown. Without the rule, a confidentiality fix would have shipped a secret-wiping write.The merge reads the flag as "no new value supplied for this key" and keeps the stored value —
hardenAppEnv/mergeAppEnvfor deployment/draft env, newrestoreWithheldEnvValuesfor the full-replacesystemEnvPATCH. An explicitremoveEnvKeysentry still wins: withholding a value on read must not make a secret undeletable. The flag is stripped on every write path, so a forged one cannot make a stored row read as withheld.Why not
AuthService.hasCapabilityprincipalHasCapabilitydecides from the principal alone.MockAuthService.hasCapabilityreturnstrueunconditionally andRealAuthServiceshort-circuits totruewhenever auth is disabled — both right for a route gate, both wrong for shaping a response: they would make this rule a no-op in exactly the configurations where a test can observe it. When auth is disabled the middleware substitutes a wildcard system principal, so a single-operator host still sees everything, by holding*rather than by the check being skipped. No principal resolved fails closed.Web
A withheld value renders
•••••••• hiddenwith no reveal control (there is nothing behind that eye — the value never reached the browser), is relaxed to optional for client-side validation so arequiredsecret nobody may read cannot silently block every save, and drops its marker the moment the operator types a replacement.An admin's experience is unchanged.
*matchesread:secrets, so the Configuration tab, Settings andhola configbehave exactly as before for the principal that was always entitled to them.Test evidence
bun run typecheck && bun run lint && bun run typecheck && bun run test && bun run build— green.route()rather thanfetch, for the same reason ascontract-broker-routes.test.ts: the auth middleware substitutes a wildcard principal whenever auth is disabled — which the test environment always is — so an HTTP-level request could never present a read-only principal, and a test that cannot present one cannot observe this rule at all.READONLY_CAPABILITIESmust not containread:secrets) — adding it there would restore the exposure in full while every redaction test still passed.Revert-proof, measured:
Residual, deliberately not done here
The tracker's own alternative — restricting the complete configuration to administrators outright — would also close this, and would additionally stop a stolen admin session or dashboard XSS from being an inventory of every app credential. That is a product decision about what the dashboard is for, not a fix for this finding, and it is not made here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh