Skip to content

Security: redact secret fields from GET /api/settings — never return raw API keys or bearer token #46

Description

@anushkrishnav

Note from me: Kindly implement a pre-commit pipeline that runs tools like bandit, semgrep, opengrep, trufflehog on the codebase.

CWE-312 (Cleartext Storage / Transmission of Sensitive Information), CWE-200 (Exposure of Sensitive Information)

Threat Note

/api/settings returns the complete settings model as JSON, including every provider API key the user has configured and the install bearer token itself — the very secret used to authenticate all requests to the backend.

Returning the bearer token from a token-gated endpoint creates a circular trust problem: anyone who gets the token can use it to retrieve the token. More importantly, a compromised renderer, a malicious Electron extension, any same-machine process that obtained the token, or any future auth-bypass vulnerability instantly harvests all stored credentials in a single HTTP call.


The Bug

Affected Files and Lines

File Lines Role
backend/apps/settings/settings.py 112–114 GET handler — dumps the entire model
backend/apps/settings/models.py 44–76 AppSettings — contains all secret fields

Evidence

backend/apps/settings/settings.py, lines 112–114:

@settings.router.get("")
async def get_settings():
    return load_settings().model_dump()   # ← full dump, every field, no redaction

backend/apps/settings/models.py, lines 44–76 — the secret fields returned:

class AppSettings(BaseModel):
    anthropic_api_key: Optional[str] = None          # ← returned plaintext
    openai_api_key: Optional[str] = None             # ← returned plaintext
    google_api_key: Optional[str] = None             # ← returned plaintext
    openrouter_api_key: Optional[str] = None         # ← returned plaintext
    claude_subscription_token: Optional[str] = None  # ← returned plaintext
    openai_subscription_token: Optional[str] = None  # ← returned plaintext
    gemini_subscription_token: Optional[str] = None  # ← returned plaintext
    openswarm_bearer_token: Optional[str] = None     # ← THE GATE SECRET, returned plaintext
    openswarm_proxy_url: Optional[str] = None
    ...

The response to a GET /api/settings with the bearer token looks like:

{
  "anthropic_api_key": "sk-ant-api03-XXXXXXXX",
  "openai_api_key": "sk-XXXXXXXX",
  "google_api_key": "AIzaXXXXXX",
  "openrouter_api_key": "sk-or-XXXXXXXX",
  "openswarm_bearer_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  ...
}

Why This Is a Problem

The settings endpoint exists so the UI can pre-fill the settings form. It does not need — and should never return — the raw secret values. A masked representation (sk-ant-…ab12, or simply is_set: true) is sufficient to show the user that a key is configured.

Returning openswarm_bearer_token is self-defeating: the token needed to make the request is handed back by the response. This means the token can be persisted, logged, or observed by any code that can make a single authenticated request.


What a Threat Actor Does

Scenario 1 Compromised Renderer / Malicious Agent Output

An LLM agent that has been manipulated (via prompt injection on a fetched page) or any XSS in the frontend can call GET /api/settings using the token it already has access to (injected via the renderer's fetch/XHR, which carries the stored token). The response delivers all API keys without any additional effort.

Scenario 2 — Same-Machine Process

Any process running as the same OS user can read the token from the filesystem/memory and then immediately call:

curl http://127.0.0.1:8324/api/settings \
  -H "Authorization: Bearer <token>"

and receive every cloud API key configured in the app.

Scenario 3 — Chained with SSRF

If an SSRF bypass is ever achieved that allows fetching authenticated internal paths (e.g., via a mis-issued token or a future auth regression), the attacker makes the agent call WebFetch("http://127.0.0.1:8324/api/settings?token=<stolen_token>") and receives all secrets in the agent's context window, from which they can be further exfiltrated.


Existing Safety Measures and Why They Fail

Measure Where Why It Fails
Bearer token auth backend/main.py:89 Required to call the endpoint — but the endpoint then hands out that same token plus every other secret
Localhost bind backend/main.py:789 Limits remote access — does nothing to prevent local code from calling the endpoint

Remediation

Create a SettingsReadView Pydantic model that excludes or masks all secret fields, and return that from the GET endpoint:

SECRET_FIELDS = {
    "anthropic_api_key", "openai_api_key", "google_api_key",
    "openrouter_api_key", "claude_subscription_token",
    "openai_subscription_token", "gemini_subscription_token",
    "openswarm_bearer_token", "openswarm_proxy_url",
}

def _mask(value: str | None) -> str | None:
    if not value:
        return None
    return value[:7] + "…" + value[-4:] if len(value) > 11 else "***"

@settings.router.get("")
async def get_settings():
    raw = load_settings().model_dump()
    for field in SECRET_FIELDS:
        if raw.get(field):
            raw[field] = _mask(raw[field])
    return raw
  • Never return openswarm_bearer_token — remove it from the response entirely.
  • The PUT handler can continue accepting the full model for writes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions