CVE-2026-63087: unauthenticated install bypass in Grafana OnCall
Grafana OnCall's self-hosted install endpoint accepted any live Grafana API token for the paired instance, then minted a fresh plugin auth token. The fix was not to authenticate first install, but to require Org Admin authority before provisioning.
CVE-2026-63087 is an authentication bypass in Grafana OnCall's self-hosted install path. SelfHostedInstallView, mounted at POST /api/internal/v1/plugin/self-hosted/install/, declares no authentication_classes, while DRF's global default is empty.
The root cause is sharper than "missing auth." The endpoint used check_token(), a HEAD api/org liveness check against the operator-configured Grafana URL, as if it proved authority. It only proved that the attacker had some valid token for that fixed Grafana instance.
Evidence status
The exploit
The attacker does not control grafana_url. That value comes from settings.SELF_HOSTED_SETTINGS["GRAFANA_API_URL"], sourced from the operator's own GRAFANA_API_URL environment variable. The attacker controls the token parsed from X-Instance-Context.
| step | what happens |
|---|---|
| 1 | Send an anonymous POST to /api/internal/v1/plugin/self-hosted/install/ with X-Instance-Context carrying a live grafana_token for that instance. |
| 2 | GrafanaHeadersMixin parses the attacker-supplied token into self.instance_context["grafana_token"]. |
| 3 | GrafanaAPIClient.check_token() sends HEAD api/org to the fixed, server-configured Grafana URL using that token. |
| 4 | A live Viewer-level token is enough, because api/org does not require admin scope. |
| 5 | The code looks up the one org row by STACK_ID and ORG_ID, then calls organization.revoke_plugin(). |
| 6 | It writes organization.api_token = grafana_api_token, replacing the stored token with the attacker's value. |
| 7 | It issues a new PluginAuthToken and returns it directly in the response body. |
| 8 | That token authenticates to the full internal API. |
organization.grafana_url = grafana_url is present in the write path, but it is not attacker data. The attacker-controlled write is organization.api_token = grafana_api_token; the payoff is the PluginAuthToken minted in step 7.
The obvious fix is a trap
The one-line patch is tempting: copy InstallView, which sets authentication_classes = (BasePluginAuthentication,), and put the same line on SelfHostedInstallView. Assurance proved that this blocks the bypass, then proved it breaks first install.
Evidence status
This is the circular-auth version of a lock installed on the inside of an empty room. It closes the hole by making the legitimate first install impossible.
The real fix
The correct boundary is not whether the token is live. It is whether the token belongs to someone with Grafana Org Admin authority on the already paired instance.
The fix adds check_admin_privilege(), implemented as GET api/org/users. Grafana gates that route to Org Admin, scope users:*, and the plugin's own plugin.json IAM manifest already requests that permission for its service account. The check runs immediately after check_token() and before any Organization lookup or write.
Non-200 from the admin check returns HTTP 400. No provisioning. No org write. No token mint.
How we verified it
Assurance tested the admin-privilege-check fix at focused security fidelity. Outbound Grafana HTTP was mocked, matching the project's own test convention; DRF and DB behavior stayed real.
| test | what it proves |
|---|---|
non_admin_401_rejected | a 401 from the admin check blocks provisioning |
non_admin_403_rejected | a 403 from the admin check blocks provisioning |
non_admin_404_rejected | a 404 from the admin check blocks provisioning |
non_admin_503_rejected | a Grafana outage does not fail open |
legit_admin_mints_valid_token | a real install still works end to end, with a token that validates |
Evidence status
The threat-model boundary is narrow and useful. The only token that clears both checks belongs to someone already Grafana Org Admin on the fixed instance, who already has native control over it through Grafana itself. The residual risk is equivalent to existing compromise.
No new secret is required. No Go-plugin change is required. No coordinated rollout is required for this fix.
What we're not claiming
We also built an optional second layer: an X-OnCall-Shared-Secret header checked with hmac.compare_digest before any DB access. Its engine side passed both gates, with a full suite result of 3334 passed, 6 skipped, 0 failed at that point in testing.
The companion Go-plugin change that would make the plugin send that header was never executed. No Go toolchain was available in the sandbox, so the plugin side was traced by reading source only. This layer is proven only halfway. It is a valid optional second factor, not a requirement for closing this CVE.
Close
A provisioning endpoint cannot treat "token is alive" as "token is allowed to provision."