Security Research
Every report below was confirmed exploitable, responsibly disclosed, and patched by the affected team.
Mass assignment on Hoppscotch's onboarding endpoint allowed injection of JWT_SECRET and SESSION_SECRET, enabling complete token forgery without any credentials.
Ghost
Webhook Delivery Fires Against Internal Network Addresses, Exposing Cloud Metadata
Cognithor
Any Visitor Could Steal Every API Key, Zero Authentication Required
LiteLLM
Org Admin Elevates Any User to Proxy Admin Across All Tenants in a Single Request
Microsoft
VibeVoice
Malicious Checkpoint File Executes Arbitrary Code Before the App Loads
Onyx AI
Hardcoded Default Credential on Impersonation Endpoint Exposes Any Tenant in Enterprise Deployments
Redash
Three Query Runners Reach Internal Services, Returning Response Bodies to the API Caller
Hoppscotch
An unauthenticated attacker can inject arbitrary configuration keys, including JWT_SECRET and SESSION_SECRET, into a fresh Hoppscotch installation through the POST /v1/onboarding/config endpoint, enabling complete server compromise. Self-hosted instances are most vulnerable during initial deployment when exposed to the internet before onboarding completion.
Four independent weaknesses enable this attack:
Weakness 1 Missing whitelist: true on ValidationPipe
Extra properties not declared in the SaveOnboardingConfigRequest DTO pass through to the service layer instead of being stripped.
Weakness 2 Unconstrained key iteration
The service uses Object.entries(dto) with a TypeScript cast as InfraConfigEnum that performs no runtime validation of which keys are allowed.
Weakness 3 Silent validation fallthrough
The validateEnvValues method’s default: break case allows security-critical keys like JWT_SECRET to pass validation without explicit checks.
Weakness 4 Unauthenticated endpoint access
The onboarding controller requires no authentication, only rate-limiting, and is accessible during fresh installs when usersCount === 0.
Attack precondition: fresh installation where onboarding is incomplete, or database state where usersCount === 0.
Step 1 Verify onboarding status
Step 2 Inject malicious configuration
Step 3 Verify compromise
Full server compromise enabling:
JWT forgery · Attacker creates valid tokens for any user, including administrators.
Complete data access · Authenticated queries expose all workspaces, collections, and team information.
Persistent access · Control of signing keys survives credential resets.
Additional injection vectors · Attacker can overwrite SESSION_SECRET, SESSION_COOKIE_NAME, RATE_LIMIT_TTL, RATE_LIMIT_MAX, ALLOW_SECURE_COOKIES, TOKEN_SALT_COMPLEXITY, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET, and other security-critical flags.
Primary fix · Enable strict input validation in main.ts:
Defence in depth: implement an allowlist of keys permitted during onboarding; add explicit rejection cases for security-critical configuration keys; protect the endpoint with a one-time setup token (similar to GitLab/Grafana patterns).
Patched in GHSA-j542-4rch-8hwf ↗. Hoppscotch v2026.5.0 enables strict ValidationPipe options and removes mass-assignment on the onboarding route. Reporter: infycore · Analysis tool: agent-kira (Offgrid Security) · Published 2026-05-28.
Cognithor
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
| Endpoint | Method | Impact |
|---|---|---|
| /api/v1/config | GET | All LLM API keys and DB passwords |
| /api/v1/credentials | GET | All stored service credentials |
| /api/v1/config/factory-reset | POST | Wipe entire user configuration |
| /api/v1/agents | GET · POST · DELETE | Enumerate, create, or delete agents |
| /api/v1/vault/stats | GET | Vault metadata |
| /api/v1/sessions/guard/violations | GET | Security audit records |
| /api/v1/isolation/secrets | GET | Isolation layer secret stats |
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.
LiteLLM
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).
Microsoft · VibeVoice
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.
What made Kira flag this with high confidence was an inconsistency in the codebase: some files already used the safe pattern with weights_only=True. Others did not. Kira reported one instance with full root cause analysis and proof of concept. Microsoft has since patched the pattern across the repository, updating multiple files and removing others entirely.
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 across the repository. Some files were removed entirely. Others were updated with weights_only=True and safe_globals verification. Offgrid Security credited as reporter.
Redash
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
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.
Onyx AI
SUPER_CLOUD_API_KEY Exposes Impersonation Endpoint in Multi-Tenant Enterprise DeploymentsA traditional scanner pointed at this codebase would have flagged a hardcoded string and filed a medium-confidence finding. Kira did something different: it traced the credential forward through the auth stack, identified what endpoint it protected, mapped what that endpoint could do, and reasoned through the full blast radius — cross-tenant session token issuance, silent access to every connected data source, and no audit trail. The hardcoded default was the entry point. What Kira surfaced was the impact.
SUPER_CLOUD_API_KEY, the credential protecting the /tenants/impersonate endpoint in Onyx Enterprise Edition, defaulted to the literal string "api_key" when the environment variable was not set. This value is publicly visible in the open-source repository. Any MULTI_TENANT deployment where SUPER_CLOUD_API_KEY was not explicitly configured was operating with a trivially known credential on a privileged platform endpoint.
The default value was hardcoded verbatim in the configuration module:
The current_cloud_superuser dependency in backend/ee/onyx/auth/users.py (pre-patch lines 40–53) validated the caller’s Authorization header against this value using a plain string comparison:
On any deployment where SUPER_CLOUD_API_KEY was not set in the environment, the effective credential was the well-known string "api_key" — present verbatim in the public source code, requiring no brute force to discover. The comparison also used != rather than a constant-time function, introducing a timing side channel.
Kira did not stop at flagging the hardcoded string. It traced the credential forward through the codebase: SUPER_CLOUD_API_KEY flows into current_cloud_superuser in auth/users.py, which is the dependency guarding POST /tenants/impersonate in server/tenants/api.py. Kira then read what that endpoint actually does — it resolves the target user across all tenants and returns a live session token. From there it mapped the downstream scope: that token authenticates against every connected data source the victim has authorised, including Google Drive, Confluence, Slack, GitHub, Jira, and Notion.
The reasoning chain Kira followed:
Step 1 — Credential discovery · SUPER_CLOUD_API_KEY defaults to "api_key" in app_configs.py. Value is public. Any deployment without explicit env var is using a known credential.
Step 2 — Auth flow trace · The credential is the only secret protecting current_cloud_superuser. The second check — SUPER_USERS email allowlist — is set by the platform operator and cannot be modified by a tenant admin.
Step 3 — Endpoint capability · /tenants/impersonate performs a global user lookup across all tenants and returns a valid session token for any target email, with no scope restriction.
Step 4 — Blast radius · The returned token is a full session credential. Kira traced it through the integration layer to enumerate what it unlocks: every data source the victim has connected. The victim has no visibility that impersonation occurred. No audit event existed (pre-patch).
That session token carries the victim’s full Onyx workspace: every document, every connector, every integration authorised under their account. Google Drive, Confluence, Slack, GitHub, Jira, Notion — all silently readable. No notification to the victim. No entry in logs (pre-patch).
Fix commit fb912b6c ↗ — June 24 2026
Three changes in a single commit:
Feature flag commit 2878d850 ↗ — June 23 2026
Adds an IMPERSONATION_ENABLED feature flag (default off). When disabled, the route is not registered at all — requests return 404 before reaching the authentication layer.
This vulnerability only affects deployments running in MULTI_TENANT mode, which is the Onyx Enterprise Edition cloud configuration. Single-tenant self-hosted deployments do not mount the /tenants/impersonate route and are not affected.
All Onyx Enterprise Edition versions before commit fb912b6c (June 24 2026) where SUPER_CLOUD_API_KEY was not explicitly set in the deployment environment are affected.
Any MULTI_TENANT deployment where SUPER_CLOUD_API_KEY was not explicitly set should be treated as potentially compromised:
1. Upgrade · Apply the patched version containing commit fb912b6c.
2. Rotate the key · Set SUPER_CLOUD_API_KEY to a strong random secret in your deployment environment. Do not rely on any default.
3. Invalidate sessions · Rotate all active user sessions in case the endpoint was used to issue tokens.
4. Review logs · Check access logs for unexpected calls to POST /tenants/impersonate before your upgrade date.
5. Notify administrators · Consider notifying affected tenant administrators per your incident response policy.
Onyx AI thanked Offgrid Security for the report and shipped fixes promptly across two commits. The CWE-798 finding (hardcoded default credential) was acknowledged as a valid catch and fully remediated: the "api_key" default is removed, the check now fails closed with HTTP 401 when the key is unconfigured, and the comparison uses secrets.compare_digest eliminating the timing side channel. No formal security advisory was published.
CWE-798 fixed in fb912b6c ↗. No advisory published — Onyx considers MULTI_TENANT mode outside documented deployment scope. Reported by Kira, Offgrid Security.