Skip to content

security(configs): return only declared configuration to non global admins, and mask credentials - #7870

Open
ar2rsawseen wants to merge 4 commits into
masterfrom
security/config-secret-masking
Open

security(configs): return only declared configuration to non global admins, and mask credentials#7870
ar2rsawseen wants to merge 4 commits into
masterfrom
security/config-secret-masking

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Jul 30, 2026

Copy link
Copy Markdown
Member

What

Configuration was returned wholesale to anyone who could read it at all, and some of it holds credentials rather than settings: an outbound proxy password, a reCAPTCHA secret, the key that signs report subscribe/unsubscribe tokens.

Two mechanisms, applied in this order.

1. An allow-list, which is the control

plugins.setReadableConfigs(namespace, {key: true}) declares what a caller who is not a global admin may read. Everything else is withheld, and a namespace with nothing declared is dropped entirely.

The direction matters. Marking credentials as secret and returning the rest is a deny-list: every new setting is exposed until somebody remembers to annotate it, so one forgotten annotation is a leaked credential with no signal. Declaring what is needed fails the other way, and the cost of forgetting is a missing input in the UI, which someone notices and files.

Declared, from surveying every reader across countly-server and countly-enterprise-plugins:

Namespace Keys Needed by
api the 13 in showInAppManagment, plus domain App Management inputs, and crash symbolication's return URL
security the password policy client-side password validation
feedback main_color, font_color, feedback_logo Ratings and Surveys widget previews
concurrent_users alert_interval the alerts drawer, declared by the consumer since the namespace belongs to an enterprise plugin

2. Marking credentials, which is the backstop

plugins.setSecretConfigs(namespace, {key: true}) masks a value as ********. It still earns its place with the allow-list in front of it:

  • it covers the values serialized into the dashboard page, a separate path from /o/configs
  • if a credential is ever declared readable by mistake, its value is withheld anyway
  • it documents intent where the config is defined

Marked: security.proxy_username, security.proxy_password, push.proxyuser, push.proxypass, reports.secretKey, recaptcha.secret_key.

Values the browser needs are deliberately not marked

recaptcha.site_key is public by design, and tracking.self_tracking_app_key is what the dashboard uses to send its own telemetry. A value the browser needs cannot be secret: hiding it from an API response while it stays in page source buys nothing. If a real credential is ever needed browser-side, the fix is to proxy the call server-side.

Before dropping anything from the page I enumerated every countlyGlobal.security.* and countlyGlobal.tracking.* reference in all three repositories. The dashboard uses exactly five keys from security, all password policy.

Three things that could have gone wrong

  • getConfig() is untouched. Server code keeps reading real values; masking there would have push and reports authenticating with the literal string ********.
  • Both filters copy. getConfig() returns a fresh object per namespace and neither filter writes into it, so a read cannot alter live configuration. Asserted by test.
  • An unset credential stays empty rather than showing a mask, so "not configured" remains distinguishable from "configured but hidden".

Writes need no guard

/i/configs is validateGlobalAdmin, so the only callers who can write configuration are the only callers who see real values, and a mask cannot be written back over a value. Worth remembering if that gate is ever loosened.

The failure mode this is built around

The dashboard degrades silently when configuration is missing: the call site uses .always(), the model initialises to {}, and the input loop is guarded by if (pluginsData[key]). So an undeclared value shows up as a setting quietly absent from a screen, with no error anywhere.

Two things address that:

  • a withheld namespace is logged at debug, so there is a trail
  • the App Management key list is read out of the frontend by the test rather than duplicated in it, so adding a key to showInAppManagment without declaring it readable fails CI instead of removing a setting from the screen

Tests

  • test/unit-tests/plugins.pluginManager.secret-configs.js, 23 cases: both filters, non-mutation, unknown namespaces, prototype keys, getConfig() still returning real values, a newly added credential nobody marked being withheld, and a credential wrongly declared readable still being masked.
  • plugins/plugins/tests.js, 5 cases through the real endpoints with a real non-global app admin.

Full unit suite: 208 passing. The 2 failures are Countly Request, which makes real HTTP calls and fails without network on a clean checkout too.

Enterprise follow-up

Enterprise plugins need declarations for namespaces they own and read: crashes.custom_domain, journey_engine.approver_enabled, and whatever data-manager uses. Until then those fall back to their existing defaults, which for journey_engine is a value already injected into the page, and for symbolication is the browser origin.

Separate issue, not fixed here

/i/configs records the full configuration as the before payload of its systemlogs entry, persisting these values indefinitely. Global-admin only to read, so not a boundary crossing, but worth masking there too.

🤖 Generated with Claude Code

…n read

Some configuration holds credentials rather than settings: an outbound proxy
password, a reCAPTCHA secret, the key that signs report subscribe tokens. Those
values were treated like any other setting, so they were returned to callers who
can read configuration but cannot set it, and they were included in the values
serialized into the dashboard page.

Adds plugins.setSecretConfigs(namespace, {key: true}), mirroring the existing
setUserConfigs, so a credential is declared where it is defined. Declared values
are masked in /o/configs for anyone who is not a global admin, and are left out
of what is serialized into the page.

getConfig() is untouched, so server code keeps reading real values rather than
authenticating with the placeholder. Masking builds a copy, so a read cannot
alter the live configuration. An unset value stays empty instead of showing a
mask, so "not configured" stays distinguishable from "configured but hidden".

Marked: security.proxy_username, security.proxy_password, push.proxyuser,
push.proxypass, reports.secretKey, recaptcha.secret_key.

Deliberately not marked, because the browser needs them and hiding a value from
an API response while it remains in page source achieves nothing:
recaptcha.site_key, which is public by design, and tracking.self_tracking_app_key,
which the dashboard uses to send its own telemetry. Every countlyGlobal.security
and countlyGlobal.tracking reference was checked first; the dashboard uses only
the password policy keys from security.

Writes need no extra guard: /i/configs is validateGlobalAdmin, so the only
callers who can write are the only callers who see real values, and a mask
cannot be written back over a value.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 30, 2026 15:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…dmins

Marking credentials as secret, added in the previous commit, is the wrong way
round on its own. Everything is returned by default and safety depends on
whoever adds the next setting remembering to annotate it, so one forgotten
annotation puts a credential back in the response with no signal.

Adds plugins.setReadableConfigs(namespace, {key: true}) and inverts the default:
a caller who is not a global admin receives only values some part of the
dashboard needs, and a namespace with nothing declared is dropped entirely. A
setting added tomorrow is private until somebody declares it, so the cost of
forgetting is a missing input rather than a leak.

Both mechanisms now apply, in that order. The allow-list is the control. Masking
is the backstop, so a credential declared readable by mistake still has its
value withheld.

Declared, from surveying every reader across countly-server and
countly-enterprise-plugins:

  api                    the 13 keys App Management renders, plus domain, which
                         crash symbolication uses to build its return URL
  security               the password policy the dashboard validates against
  feedback               the three colours the ratings widget previews with
  concurrent_users       alert_interval, declared by the alerts plugin that
                         reads it, since the namespace belongs to an enterprise
                         plugin in another repository

A namespace being withheld is logged at debug, because the dashboard degrades
silently when configuration is missing: the call site uses .always(), the model
initialises to {}, and the loop is guarded, so an undeclared value shows up as
an input quietly absent from a screen rather than an error.

The App Management list is read out of the frontend by the test rather than
duplicated in it, so adding a key there without declaring it readable fails CI
instead of removing a setting from the screen.

Enterprise plugins need matching declarations for the namespaces they own and
read: crashes.custom_domain, journey_engine.approver_enabled and whatever
data-manager needs. Until then those degrade to their existing fallbacks.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 09:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ar2rsawseen ar2rsawseen changed the title security(configs): mark credential configs secret and withhold them on read security(configs): return only declared configuration to non global admins, and mask credentials Jul 31, 2026
The reduction is only correct if everything that legitimately reads configuration
still works, so assert the values arrive with their real contents rather than
only that credentials are gone:

  the app admin receives real values, of the right types, with no placeholder
  anywhere in the response, since App Management infers each input's widget from
  the type it sees

  every value the app admin can read is identical to what a global admin reads,
  because the app settings screen shows how an app differs from the server
  default and that is only meaningful against the real defaults

  the ratings widget colours arrive, which is the styling path

  a global admin can still write configuration, written back unchanged so the
  test leaves nothing altered

  an app admin still cannot write, which is the reason a mask can never be saved
  back over a real value

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The ratings colours assertion required the feedback namespace to be present.
getAllConfigs omits a namespace whose plugin is not enabled, so in a run without
star-rating the namespace is absent for a global admin too and there is nothing
to reduce. The test was asserting which plugins the run enabled rather than the
behaviour under test, and failed on the countly-platform shard that excludes it.

Now asserts the property that holds either way: if the namespace arrives, its
declared keys are present with real values.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 10:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants