Skip to content

config create/update store #-prefixed secrets as plaintext (Encryption API only applied on sync/variables paths) #378

Description

@soustruh

Guys, I believe I just found quite a bug — please correct me if I'm completely missing something and this isn't actually a problem…

TL;DR

Secrets passed to the interactive config create / config update (and new-row / update-row) commands are written to Storage in plaintext, because those paths never call the Encryption API. The Storage API does not encrypt #-prefixed values server-side — the client must pre-encrypt. The UI does this; our own sync push and variables paths already do it via encrypt_secrets_in_config. The interactive config commands just skip it.

This is the AI-facing default path: an agent (or a human) doing config update … '#password=…' lands the secret readable in the project, in every config version, and re-exposed on the next read.

How I noticed it

I spotted the #password stored in cleartext directly (not KBC::…).

The job angle is worth stating precisely, because it cuts the other way: when the component actually runs, the runtime rejects the plaintext secret —

Invalid cipher text for key #password  Value "…" is not an encrypted value.

— so the job fails closed. That is not a safeguard. The plaintext value is readable straight out of Storage (API / UI / every config version), and a sync action (e.g. testConnection) executes with the live plaintext credential — so it can be pulled out of Keboola unencrypted even though the job won't run. Failing closed on the job just hides the leak.

Repro (verified live, dummy value)

Create a config with a #-prefixed param via config create (or raw Storage API), then read it back:

  • stored value is the literal plaintext, not KBC::…
  • the same value pushed via sync push comes back KBC::…

Storage stores whatever JSON it is handed; it does not encrypt. The responsibility is entirely client-side.

Where it is (current main)

Already correct (call services/_encryption.py::encrypt_secrets_in_config, fail-closed):

  • sync pushsync_service.py (_push_create / _push_update / _push_*_row)
  • variables — variables_service.py

Missing the call → writes plaintext:

  • config_service.pycreate_config / update_config / create_config_row / update_config_rowthe real exposure (extractor/writer credentials)
  • routers/configs.py — the serve REST config routes (share config_service)
  • flow_service.py, data_app_service.py — same unguarded call, but flows/data-apps hold references to other configs and don't normally carry #-secrets, so this is theoretical, not an active leak. Listing only for completeness — I don't want to overstate it.
  • kbagent tool MCP passthrough — defers to the MCP server, which (separately) also doesn't encrypt.

All writes funnel through the Storage client in client.py, so there's a single chokepoint available if you want one.

Relationship to existing issues

Fix options (your call — flagging, not prescribing)

  1. Central chokepoint — encrypt #-keys in the Storage client write methods (create_config / update_config / *_row, or in _request for /configs* POST/PUT). One fix, also covers serve + the passthrough. Needs project/component context at that layer.
  2. Service-layer — call encrypt_secrets_in_config in config_service (and flow/data-app) the way sync_service already does. Proven pattern, more call sites.
  3. Hybrid — chokepoint as a safety net + explicit service calls.

Whatever the choice: reuse encrypt_secrets_in_config, keep its fail-closed default, and expose an escape hatch named consistently with sync's --allow-plaintext-on-encrypt-failure.

Open questions

  • Should reads (get_config) redact #-values before handing them back to an LLM, to avoid context leakage? (Separate concern, same theme.)
  • Should the kbagent tool MCP passthrough encrypt itself, or rely on the MCP server being fixed?

Verified on project 4214, component keboola.ex-pohoda-mserver. Happy to share the probe configs / full repro. Tracking in Linear: AI-3326. Sibling finding — the Keboola MCP server has the same behaviour: AI-3327.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions