Stu Mason
Stu Mason

Activity

Issue Resolved

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_github
  • manual_webhook_secret_gitlab
  • manual_webhook_secret_gitea
  • manual_webhook_secret_bitbucket
  • http_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:

  1. 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;
    
  2. In listResources(), walk each item and replace each sensitive field with '***' unless the caller passed reveal: true. Apply at the API boundary in the client (not the tool handler) so any other code path that calls listResources() also gets masking by default.

  3. On the system tool, accept reveal?: boolean on the list_resources action and thread it through. Match the existing env_vars({ reveal }) ergonomics exactly so callers don't have to learn a new flag name.

  4. Add a test that asserts default response has *** in each sensitive field and that reveal: true round-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_resources defaults 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 pass include_full: true.
  • Happy to send the PR.