Vulnerability research

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

affected codeengine/apps/grafana_plugin/views/self_hosted_install.py, SelfHostedInstallView
routePOST /api/internal/v1/plugin/self-hosted/install/
authenticationno authentication_classes, DRF global default empty
known identifiersSTACK_ID defaults to 5, ORG_ID is hardcoded to 100
validated targetgrafana/oncall commit af0fbd4, relocated to the archived grafana-cold-storage/oncall, AGPLv3
01

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.

stepwhat happens
1Send an anonymous POST to /api/internal/v1/plugin/self-hosted/install/ with X-Instance-Context carrying a live grafana_token for that instance.
2GrafanaHeadersMixin parses the attacker-supplied token into self.instance_context["grafana_token"].
3GrafanaAPIClient.check_token() sends HEAD api/org to the fixed, server-configured Grafana URL using that token.
4A live Viewer-level token is enough, because api/org does not require admin scope.
5The code looks up the one org row by STACK_ID and ORG_ID, then calls organization.revoke_plugin().
6It writes organization.api_token = grafana_api_token, replacing the stored token with the attacker's value.
7It issues a new PluginAuthToken and returns it directly in the response body.
8That 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.

02

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

security gatePASS, fidelity focused
red runanonymous POST returned 201, minted token, hijacked org
green runsame request returned 401, provisioning never ran
engineering gateFAIL
why it failsSelfHostedInstallView issues the first PluginAuthToken, but BasePluginAuthentication requires that token before the endpoint can return it
project tests6 of the project's own pre-existing tests failed under the naive patch

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.

03

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.

engine/apps/grafana_plugin/helpers/client.py
11 def check_token(self):
22 return self.api_head("api/org")
33  
4+ def check_admin_privilege(self):
5+ return self.api_get("api/org/users")
engine/apps/grafana_plugin/views/self_hosted_install.py
1+ _, admin_check_status = grafana_api_client.check_admin_privilege()
2+ if admin_check_status["status_code"] != status.HTTP_200_OK:
3+ provisioning_info["error"] = f"The supplied Grafana API token does not have Admin privileges on the specified Grafana API - {grafana_url}"
4+ return Response(data=provisioning_info, status=status.HTTP_400_BAD_REQUEST)
15  
26 organization = Organization.objects.filter(stack_id=stack_id, org_id=org_id).first()

Non-200 from the admin check returns HTTP 400. No provisioning. No org write. No token mint.

04

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.

testwhat it proves
non_admin_401_rejecteda 401 from the admin check blocks provisioning
non_admin_403_rejecteda 403 from the admin check blocks provisioning
non_admin_404_rejecteda 404 from the admin check blocks provisioning
non_admin_503_rejecteda Grafana outage does not fail open
legit_admin_mints_valid_tokena real install still works end to end, with a token that validates

Evidence status

security gatePASS, fidelity focused
red rununpatched code let a live-but-non-admin token reach org-provisioning logic, reproduced live as a ValueError inside is_rbac_enabled_for_organization, 7 failed / 14 passed on the affected tests
green run21 passed, all four non-admin status codes rejected, no org created, no token minted
engineering gatePASS
legit installadmin token returned 201 and round-tripped through real, unmocked PluginAuthToken.validate_token_string()
full suite3332 passed, 6 skipped, 0 failed

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.

05

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.

06

Close

A provisioning endpoint cannot treat "token is alive" as "token is allowed to provision."