CVE-2026-63087: unauthenticated admin takeover via Grafana OnCall's v2 install endpoint
Grafana OnCall's v2 install endpoint ships with auth off, minting a plugin token to any anonymous caller; a second flaw grants Admin. We verified the fix.
CVE-2026-63087 is an unauthenticated compromise of self-hosted Grafana OnCall. The install endpoint InstallV2View, mounted at POST /api/internal/v1/plugin/v2/install/, sets both authentication_classes and permission_classes to empty. Its own parent view enforces plugin authentication. The install subclass removes it.
Evidence status
The endpoint turns off its own parent's auth
SyncV2View is authenticated. InstallV2View extends it and overrides the auth away to nothing.
class InstallV2View(SyncV2View):
authentication_classes = ()
permission_classes = ()
def post(self, request: Request) -> Response:
...
organization = self.do_sync(request)
organization.revoke_plugin()
provisioned_data = organization.provision_plugin()
return Response(data=provisioned_data, status=status.HTTP_200_OK)
The do_sync it inherits does not re-establish any identity. On the open-source branch it reads the expected stack_id and org_id from server settings and only checks that the request body matches them. Both are known values: org_id is hardcoded to 100, and stack_id defaults to 5, so an attacker simply supplies them. The serializer requires a grafana_token string in the body, but nothing ever verifies it.
def do_sync(self, request: Request) -> Organization:
serializer = SyncDataSerializer(data=data)
if not serializer.is_valid():
raise SyncException(serializer.errors)
sync_data = serializer.save()
if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:
stack_id = settings.SELF_HOSTED_SETTINGS["STACK_ID"] # 5
org_id = settings.SELF_HOSTED_SETTINGS["ORG_ID"] # 100
...
if sync_data.settings.org_id != org_id or sync_data.settings.stack_id != stack_id:
raise SyncException(INVALID_SELF_HOSTED_ID)
organization = get_or_create_organization(...)
apply_sync_data(organization, sync_data)
return organization
So a match against two known constants is the only gate, and provision_plugin() then returns a fresh plaintext token to a caller who proved nothing.
The second flaw: a header that assigns your own role
The minted token authenticates through PluginAuthentication. When it resolves a user that does not exist yet, it falls back to a request header and trusts the role written there.
except User.DoesNotExist:
user_data = dict(json.loads(request.headers.get("X-Oncall-User-Context")))
...
user_sync_data = SyncUser(
id=user_data["id"],
...
role=user_data["role"], # attacker-supplied, trusted verbatim
...
)
return get_or_create_user(organization, user_sync_data)
get_or_create_user maps that string to a database role with no check. Supply "role": "Admin" and you are Admin.
The exploit
| Step | What happens |
|---|---|
| 1 | Anonymous POST /api/internal/v1/plugin/v2/install/ with a body carrying the public defaults stack_id: 5, org_id: 100. |
| 2 | InstallV2View has no authentication_classes, so the request is never authenticated. |
| 3 | do_sync confirms only that the body matches the two default IDs. The supplied grafana_token is never verified. |
| 4 | provision_plugin() mints a plaintext onCallToken and returns it. |
| 5 | The caller presents that token plus X-Oncall-User-Context: {"role":"Admin", ...} to any PluginAuthentication endpoint. |
| 6 | On User.DoesNotExist, the header's role is trusted and an Admin user is created. |
| 7 | The caller is Admin: create integrations, revoke the legitimate plugin token, repoint OnCall's Grafana calls at an attacker-controlled host. |
No token, no credential, and no prior access is required at any step.
The admin-check fix is a trap
The tempting fix is to require that the caller be a Grafana Org Admin before provisioning. We built that and ran it against the real view. It does not work, and the reason is structural: there is no authenticated principal on this endpoint to check. The only grafana_token in the request is the attacker's own body value, pointing at a grafana_url the attacker also controls. An admin check asks that attacker-controlled Grafana whether the attacker is an admin, and it happily says yes.
Evidence status
An authority check only means something once there is an authenticated identity. Here there is none, so the check is decoration.
The fix that actually closes it
Two changes, both required. Neither alone is enough.
1. Put real authentication on the install endpoint. Not the plugin token this endpoint mints, which would lock out first install, but an unforgeable, operator-provisioned install secret the attacker cannot supply.
2. Stop trusting the client-supplied role in the user-context bootstrap. Derive the role server-side instead of reading it from the attacker-controlled header.
Applying this is a contract change. The install endpoint now requires a header no client sends today, so every legitimate install path, the Helm chart, provisioning scripts, and OnCall's own install test, has to be updated to send the install secret in lockstep.
How we verified it
Assurance stood up the real OnCall engine for this: real Django 4.2.27 and DRF 3.15.2, a real database with all migrations applied, and every request driven through the project's own URLconf, middleware, and DRF dispatch. We confirmed each result by inspecting the database directly after every request. Fidelity is full; the only thing not real was the external Grafana the endpoint calls, and the vendor's own client handles an unreachable Grafana exactly as the harness saw it.
Evidence status
The fix nobody upstream is going to ship
OnCall is archived at 1.16.11 and moved to cold storage, so no vendor patch is coming. The change above is the one we verified, on the real view under real DRF, actually stops the exploit: the empty-auth endpoint stops minting tokens to anonymous callers, and the trusted-role header stops handing out Admin. It is here for operators holding the line while they migrate off, who need to build from source in the meantime.
An install endpoint that switches off its own parent's authentication is not a smaller version of a secured endpoint. It is an unauthenticated one, and no check you add after the token is minted can put the authentication back.