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 push — sync_service.py (_push_create / _push_update / _push_*_row)
- variables —
variables_service.py
Missing the call → writes plaintext:
config_service.py — create_config / update_config / create_config_row / update_config_row ← the 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)
- 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.
- 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.
- 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.
TL;DR
Secrets passed to the interactive
config create/config update(andnew-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 ownsync pushand variables paths already do it viaencrypt_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
#passwordstored in cleartext directly (notKBC::…).The job angle is worth stating precisely, because it cuts the other way: when the component actually runs, the runtime rejects the plaintext secret —
— 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 viaconfig create(or raw Storage API), then read it back:KBC::…sync pushcomes backKBC::…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 push—sync_service.py(_push_create/_push_update/_push_*_row)variables_service.pyMissing the call → writes plaintext:
config_service.py—create_config/update_config/create_config_row/update_config_row← the real exposure (extractor/writer credentials)routers/configs.py— theserveREST config routes (shareconfig_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 toolMCP 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
kbagent encryptprimitive (the building block) — but the interactive config commands don't call it.config pushauto-encrypt#-values?" as an open question — this is concrete evidence that forconfig create/updateit isn't just nice-to-have, it's an active plaintext write.kbc push(Linear PSGO-85).Fix options (your call — flagging, not prescribing)
#-keys in the Storage client write methods (create_config/update_config/*_row, or in_requestfor/configs*POST/PUT). One fix, also coversserve+ the passthrough. Needs project/component context at that layer.encrypt_secrets_in_configinconfig_service(and flow/data-app) the waysync_servicealready does. Proven pattern, more call sites.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
get_config) redact#-values before handing them back to an LLM, to avoid context leakage? (Separate concern, same theme.)kbagent toolMCP 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.