Summary
_resolve_db_in_config() expands a component's db id-reference into the fully-resolved
resolved_db.to_dict(), which for PostgresDb/SqliteDb includes db_url — and a Postgres URL
embeds the password. That expanded block is persisted and returned verbatim by
GET /components/{id}/configs/current | /configs/{version} | /configs, gated only by
components:read/components:write (NOT admin/config:read). A low-privilege Studio/components
principal thus recovers the backend DB master credentials.
Affected code (main @ 07fe6b23)
os/routers/components/components.py _resolve_db_in_config: config["db"] = {**resolved_db.to_dict(), **table_overrides}.
db/postgres/postgres.py:170-179 to_dict() → includes "db_url": self.db_url (password in URL).
db/sqlite/sqlite.py:158-163 to_dict() → includes db_url/db_file.
GET /components/*/configs* returns ComponentConfigResponse.config verbatim under components:read.
Root cause
The DB to_dict() used for the stored/returned config carries the secret db_url; the resolve step
bakes it into the component config; components:read is not an admin scope. Secrets round-trip to
non-admin callers.
Preconditions / attacker model
- A principal with
components:read (Studio surface). Confirmed 403 on /config (genuinely low-priv).
Unauthenticated in default no-OS_SECURITY_KEY deployment. Pre-existing Studio configs already carry
resolved creds, so even read-only suffices.
Impact — HIGH
DB master password disclosure → full data-plane compromise (sessions, memories, the auth_tokens
table, all tenants) and lateral movement to the DB host.
Reproduction (mechanism)
POST /components referencing a db by id ({"db":{"id":"prod-postgres"}}), then
GET /components/{id}/configs/current with a components:read token → response config.db.db_url
contains postgresql+psycopg://<user>:<password>@host:5432/db. Local PoC:
/mnt/ntfs-p2/agno-hunt-2026-07-02/poc/agentG/poc-1.py (fake creds). Captured:
GET /components/my-studio-agent/configs/current -> 200
config.db.db_url = postgresql+psycopg://agno_admin:SuperSecretDbPassw0rd@prod-db.internal:5432/agno_prod
db_url leaked to components:read token = True
Expected vs actual
- Actual: resolved component config contains connection secrets (
db_url w/ password), returned to
components:read.
- Expected: component config responses never contain connection secrets; the
db stays a non-secret
id reference (or secrets are redacted).
Suggested fix
Do not embed the secret-bearing db_url in the stored/returned component config. Options:
- Keep
db as {"id": ..., <table overrides>} in persisted/returned config; resolve the live db
(with its db_url) only in-memory at load time — never persist/return the expansion.
- Add
to_dict(redact_secrets=True) (drops db_url/password) and use it for the resolve/response path.
- At minimum, strip
db_url/credentials from ComponentConfigResponse.config before returning.
Duplicate / publicness (GitHub API, this session)
No issue/PR/GHSA reports component-config credential disclosure (db_url searches: 0). #8477 covers
NON-secret model connection params only (Model.to_dict), not db_url. Related to #8702 (same
function, injection vector); distinct impact.
Environment tested
- agno 2.6.20, main @ 07fe6b2, Python 3.12. Local-only reproduction; fake creds.
Summary
_resolve_db_in_config()expands a component'sdbid-reference into the fully-resolvedresolved_db.to_dict(), which forPostgresDb/SqliteDbincludesdb_url— and a Postgres URLembeds the password. That expanded block is persisted and returned verbatim by
GET /components/{id}/configs/current | /configs/{version} | /configs, gated only bycomponents:read/components:write(NOT admin/config:read). A low-privilege Studio/componentsprincipal thus recovers the backend DB master credentials.
Affected code (
main@07fe6b23)os/routers/components/components.py_resolve_db_in_config:config["db"] = {**resolved_db.to_dict(), **table_overrides}.db/postgres/postgres.py:170-179to_dict()→ includes"db_url": self.db_url(password in URL).db/sqlite/sqlite.py:158-163to_dict()→ includesdb_url/db_file.GET /components/*/configs*returnsComponentConfigResponse.configverbatim undercomponents:read.Root cause
The DB
to_dict()used for the stored/returned config carries the secretdb_url; the resolve stepbakes it into the component config;
components:readis not an admin scope. Secrets round-trip tonon-admin callers.
Preconditions / attacker model
components:read(Studio surface). Confirmed 403 on/config(genuinely low-priv).Unauthenticated in default no-
OS_SECURITY_KEYdeployment. Pre-existing Studio configs already carryresolved creds, so even read-only suffices.
Impact — HIGH
DB master password disclosure → full data-plane compromise (sessions, memories, the
auth_tokenstable, all tenants) and lateral movement to the DB host.
Reproduction (mechanism)
POST /componentsreferencing a db by id ({"db":{"id":"prod-postgres"}}), thenGET /components/{id}/configs/currentwith acomponents:readtoken → responseconfig.db.db_urlcontains
postgresql+psycopg://<user>:<password>@host:5432/db. Local PoC:/mnt/ntfs-p2/agno-hunt-2026-07-02/poc/agentG/poc-1.py(fake creds). Captured:Expected vs actual
db_urlw/ password), returned tocomponents:read.dbstays a non-secretid reference (or secrets are redacted).
Suggested fix
Do not embed the secret-bearing
db_urlin the stored/returned component config. Options:dbas{"id": ..., <table overrides>}in persisted/returned config; resolve the live db(with its
db_url) only in-memory at load time — never persist/return the expansion.to_dict(redact_secrets=True)(dropsdb_url/password) and use it for the resolve/response path.db_url/credentials fromComponentConfigResponse.configbefore returning.Duplicate / publicness (GitHub API, this session)
No issue/PR/GHSA reports component-config credential disclosure (
db_urlsearches: 0). #8477 coversNON-secret model connection params only (Model.to_dict), not
db_url. Related to #8702 (samefunction, injection vector); distinct impact.
Environment tested