Issue #204 closed: system list_resources exposes webhook secrets and basic-auth credentials in MCP responses — apply v2.9.0 env_vars masking pattern
Summary
system({ action: 'list_resources' }) (v2.11.0 / #172) returns the full Coolify /api/v1/resources response, which embeds several per-resource secrets in plaintext on every application record:
manual_webhook_secret_githubmanual_webhook_secret_gitlabmanual_webhook_secret_giteamanual_webhook_secret_bitbuckethttp_basic_auth_password
These are the HMAC secrets used to validate inbound deploy webhooks (forging one lets an attacker trigger a deploy on that repo with controlled payload), and the HTTP basic-auth password used to gate front-of-app access. They are currently returned to any MCP client / LLM that calls list_resources, with no opt-in.
This is the same exposure class that PR #159 / #182 fixed for env_vars in v2.9.0 — value and real_value were being shipped in plaintext and are now masked by default with an explicit reveal: true opt-in. The same posture should apply here.
Reproduction
On any Coolify v4 instance with at least one git-deployed application:
# via MCP
system({ action: 'list_resources' })
# equivalent HTTP
curl -H "Authorization: Bearer $COOLIFY_TOKEN" \
https://<coolify>/api/v1/resources | jq '.[] |
{ uuid, manual_webhook_secret_github, manual_webhook_secret_gitlab,
manual_webhook_secret_gitea, manual_webhook_secret_bitbucket,
http_basic_auth_password }'
Every application row has the four manual_webhook_secret_* strings populated (they are auto-generated by Coolify even when the user only uses one provider) plus the basic-auth password if is_http_basic_auth_enabled.
Impact
- Webhook-secret leakage: an MCP client / LLM that has been granted permission to call
list_resources(a "read-only enumeration" tool by name and description) silently also learns four HMAC secrets per app. A malicious or compromised LLM agent can use them to forge deploy webhooks. The user granting "list resources" permission almost certainly did not intend to also grant "exfiltrate every webhook signing key". - Token-equivalent escalation:
manual_webhook_secret_github(and friends) are independent of the Coolify API token — an attacker who pivots through them retains the ability to trigger deploys even after the Coolify API token is rotated. - Basic-auth password: same pattern. Read-only enumeration shouldn't divulge the password.
- The Coolify API token holder is naturally trusted with this data, but the point of the MCP layer is to expose a narrower surface to LLM clients than the raw token — same reasoning as #159 / #182.
Proposed fix
Apply the v2.9.0 pattern exactly:
-
Define a hard-coded list of sensitive fields per resource type:
const SENSITIVE_RESOURCE_FIELDS = [ 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_gitea', 'manual_webhook_secret_bitbucket', 'http_basic_auth_password', ] as const; -
In
listResources(), walk each item and replace each sensitive field with'***'unless the caller passedreveal: true. Apply at the API boundary in the client (not the tool handler) so any other code path that callslistResources()also gets masking by default. -
On the
systemtool, acceptreveal?: booleanon thelist_resourcesaction and thread it through. Match the existingenv_vars({ reveal })ergonomics exactly so callers don't have to learn a new flag name. -
Add a test that asserts default response has
***in each sensitive field and thatreveal: trueround-trips the original value.
Notes
- This is a defense-in-depth fix at the MCP boundary; the underlying Coolify API obviously still returns these fields. The MCP layer is the right place to apply masking because that is where the trust boundary narrows.
- Pairs with the essential-projection work in #203 — once
list_resourcesdefaults to the essential projection, the leaked secret fields aren't in the default response at all. Masking still matters because it protects callers who legitimately passinclude_full: true. - Happy to send the PR.