Vulnerability research

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

affectedGrafana OnCall through 1.16.11, repo archived to grafana-cold-storage/oncall
endpointInstallV2View, POST /api/internal/v1/plugin/v2/install/
authenticationauthentication_classes = () and permission_classes = (); the parent SyncV2View requires BasePluginAuthentication
preconditionnone; the attacker posts the public default STACK_ID 5 and ORG_ID 100
reproduced by Assuranceunauthenticated POST returned 200 and wrote a real Organization and token to the database, on the running OnCall engine under DRF 3.15.2 and Django 4.2.27
01

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.

02

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.

03

The exploit

StepWhat happens
1Anonymous POST /api/internal/v1/plugin/v2/install/ with a body carrying the public defaults stack_id: 5, org_id: 100.
2InstallV2View has no authentication_classes, so the request is never authenticated.
3do_sync confirms only that the body matches the two default IDs. The supplied grafana_token is never verified.
4provision_plugin() mints a plaintext onCallToken and returns it.
5The caller presents that token plus X-Oncall-User-Context: {"role":"Admin", ...} to any PluginAuthentication endpoint.
6On User.DoesNotExist, the header's role is trusted and an Admin user is created.
7The 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.

04

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

admin-check fix, applied to InstallV2Viewunauthenticated POST still returned 200 and still minted a token
exploit steps blocked0 of 2
whyno authenticated principal exists to attach an authority check to; the token and the Grafana it validates against are both attacker-supplied

An authority check only means something once there is an authenticated identity. Here there is none, so the check is decoration.

05

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.

engine/apps/grafana_plugin/views/install_v2.py
11 class InstallV2View(SyncV2View):
22 authentication_classes = ()
33 permission_classes = ()
44  
55 def post(self, request: Request) -> Response:
6+ # Require an unforgeable, operator-provisioned install secret the
7+ # attacker cannot supply. Not the plugin token this endpoint issues.
8+ if request.headers.get("X-Self-Hosted-Install-Secret") != settings.SELF_HOSTED_INSTALL_SECRET:
9+ return Response(status=status.HTTP_401_UNAUTHORIZED)
610 ...

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.

engine/apps/auth_token/auth.py
11 user_sync_data = SyncUser(
22 id=user_data["id"],
33 ...
4- role=user_data["role"],
4+ role=LegacyAccessControlRole.NONE, # never trust a client-supplied role
55 ...
66 )

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.

06

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

red, unpatchedunauthenticated POST wrote a real Organization and PluginAuthToken to the database, 0 rows to 1, and the forged X-Oncall-User-Context role Admin created a real ADMIN user
green, patchedthe same attack returned 401 with zero rows written, and the forged Admin role mapped to NONE
the admin-check fix, for contrastleft the exploit open: 200, token minted, 0 of 2 steps blocked
legitimate installwith the correct install secret, a real install still completed end to end: 200, token minted, org provisioned, no lockout
app healthboots clean (manage.py check reported no issues), migrations consistent, patched files import cleanly
project test suite150 passed, 1 failed; the one failure is the shipped install test omitting the new secret header, confirmed a pure contract change (it passes on unpatched code and passes again once the header is added)
fidelityfull: real Django app, real database, real dispatch, migrations applied
07

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.