Grafana OSS, SignUpStep2 invite join without the code
Grafana OSS 13.2.3 SignUpStep2 joins every pending org invite for the claimed email when signup is on and email verify stays off. The 30-char invite code is never required. Role can be Admin. No CVE yet.
- Name
- Grafana OSS, SignUpStep2 invite join without the code
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Grafana Labs
- Affected
- Grafana OSS 13.2.3 (6193dc0). Needs users.allow_sign_up=true. Email verify default off. Stock conf/defaults.ini keeps signup off.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, grafana, privilege-leftover, invite, cwe-862, unauthenticated
The invite code is optional on signup
I am @abraxas_null. The proof of concept is on GitHub: abraxas/grafana-signup-invite-join (loopback client). The lab stack is lab/: docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Grafana OSS 13.2.3 (6193dc03311b631b9727b560d24369e683dc396e). No CVE yet. CWE-862. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). Remote privilege leftover. Unauthenticated HTTP. Needs open signup.
When users.allow_sign_up is true and users.verify_email_enabled stays at the default false, unauthenticated POST /api/user/signup/step2 with an invited email creates the account and applyUserInvite attaches every InvitePending row for that address at invite.Role. The 30-character invite code is never required on this path. Role can be Admin.
The complete-invite path still binds the code. This is SignUpStep2.
Why Grafana
I had just finished unpublished Crit/High hunts of Prometheus and PostgreSQL. Default installs came back empty. Grafana is a large Go tree with a security model that looks like a hole until you read defaults.ini, and then looks like a leftover when an operator turns one flag on.
I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. Grafana Enterprise, Cloud-only, grafana-operator, mcp-grafana, and Alloy are other trees.
Pinning 13.2.3
Latest GA: v13.2.3, commit 6193dc0. Image grafana/grafana:13.2.3. That is the pin. It already ships CVE-2026-13719, CVE-2026-13720, CVE-2026-81841, and the 81842 family.
The skip list is half the hunt
grafana.com/security/security-advisories on this pin:
- CVE-2026-13719 Alert rules list drops folder restriction.
- CVE-2026-13720 Editor forges file-provisioning annotations. Closed by
enforceClassicFPAssignment. - CVE-2026-81841 Paused shared-dashboard tokens leak DS config.
- CVE-2026-81842 Library panel move without dest create.
- CVE-2026-76154 Geomap MapLibre XSS.
- CVE-2026-19197 Snapshot ACL.
- CVE-2026-17033 Alertmanager generatorURL XSS.
- CVE-2026-21723 Templates Test DoS.
- CVE-2026-11817 SearchUsersPermissions cross-org.
- CVE-2026-33382 web.Bind oversized body DoS.
- CVE-2026-42129 / CVE-2026-10601 Loki/Tempo path traversal.
- CVE-2026-15583 / CVE-2026-19516 Grafana MCP
X-Grafana-URL(different repo). - CVE-2022-39306 complete-invite email bind. Still present. Not this route.
Enterprise-only CVEs stay on the Enterprise tree. I did not re-file any of that.
Grafana G2 (IAM SSOSetting k8s authorizer Allow-all when kubernetesSsoSettingsApi=true) is flag-gated and already-fixed-on-dev. Skip. This post is G1.
Twenty surfaces, one leftover on signup
I split the tree. Authn/session, RBAC/IDOR, datasource proxy SSRF, public dashboards/snapshots, alerting, plugins, XSS, rendering, Live WS, OAuth/LDAP, provisioning/Git Sync, query/expr, secrets, CSRF/redirect, k8s apiserver, SCIM/invites, 13.2.3 patch siblings, admin/files, frontend stored XSS, cloud migration/SMTP, annotations/search.
Default OSS 13.2.3 came back empty at this bar. Stock conf/defaults.ini keeps signup off, kubernetesSsoSettingsApi off, unsigned plugins unloaded, datasource proxy on the stored DS URL.
Hunts that returned nothing at this bar: authn-session, datasource-proxy-ssrf, public-snapshots, alerting, plugins, xss, rendering, live-ws, oauth-ldap, query-expr, secrets, csrf-redirect, admin-files, frontend, cloud-smtp, patch-siblings.
SCIM on OSS wires a nil k8s client. Invite codes are crypto/rand 30-char alphanum. Anonymous org role does not escalate without operator ini. Annotations tags leak keys, not text. Playlists RoleNone hotfix is names plus UIDs. CSRF GET ?orgId= is SameSite=Lax.
Then I opened signup.
SignUpStep2 never asks for the code
POST /api/user/signup/step2 is unauthenticated (pkg/api/api.go). SignUpStep2 creates the user, then loads pending invites by email. form.Code is only required when VerifyEmailEnabled is true.
if hs.Cfg.VerifyEmailEnabled {
if ok, rsp := hs.verifyUserSignUpEmail(c.Req.Context(), form.Email, form.Code); !ok {
return rsp
}
}
usr, err := hs.userService.Create(c.Req.Context(), &createUserCmd)
invitesQuery := tempuser.GetTempUsersQuery{Email: form.Email, Status: tempuser.TmpUserInvitePending}
invitesQueryResult, err := hs.tempUserService.GetTempUsersQuery(c.Req.Context(), &invitesQuery)
for _, invite := range invitesQueryResult {
if ok, rsp := hs.applyUserInvite(c.Req.Context(), usr, invite, false); !ok {
return rsp
}
apiResponse["code"] = "redirect-to-select-org"
}applyUserInvite adds usr to invite.OrgID at invite.Role and marks the temp row Completed. The invite code is not an argument. updateTempUserStatus on form.Code does not check rows-affected, so an empty code is fine when verify is off.
CompleteInvite is the intended join path and still requires the invite code. Signup step2 is a second, weaker join.
Stock [users] allow_sign_up = false (conf/defaults.ini). setting.go MustBool(true) only applies when the key is missing. An operator who turns signup on without turning email verify on is the exposed case.
Wrong turns, in the order I made them
Filing default OSS. Signup is off. The leftover is the open-signup path.
Re-filing CVE-2022-39306. That was complete-invite accepting a different email while holding the code. This pin still binds complete-invite email. This leftover is "know the invited email, skip the code."
Unauth SCIM. Enterprise/Cloud API. OSS sink is a nil k8s client.
Invite-code prediction. 30-char crypto/rand. Not this path. This path never needs the code.
Grafana Admin (isGrafanaAdmin). Org Admin on the invited org. Not instance admin.
A reverse shell. Theatre. The witness is sqlite org_user role=Admin.
What an attacker can do
Needs the invited address (the invite email, a guess, or another leak). Does not need the mailbox and does not need the invite code. Does not need an existing Grafana account.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
| Create an account as the invited email | Unauth POST /api/user/signup/step2 with code="" |
Integrity (account create) | Lab-proven. Session /api/user login=invitee |
| Join the invited org at the invite role (Admin in this lab) | applyUserInvite from GetTempUsersQuery{Email, InvitePending} |
Integrity (org membership + role) | Lab-proven. /api/user/orgs role=Admin; sqlite org_user Admin |
| Complete the pending invite so the real invitee cannot use it | updateTempUserStatus(..., TmpUserCompleted) |
Integrity (invite consumption) | Lab-proven. Pending list empty; sqlite temp status=Completed |
| Same join for Viewer/Editor invites | invite.Role is copied as-is |
Integrity | Inferred. Lab used Admin |
| Read/write that org as Org Admin after login | Org Admin APIs (datasources, dashboards, users) | Recon + integrity | Inferred. Lab stops at role=Admin |
| Negative control | Signup as a different email with empty code | n/a | Lab-proven. invite-org-role=none |
Closed on this leftover: default install with signup off; email-verify-on path (that path checks form.Code); complete-invite email bind (CVE-2022-39306 class, still present); Grafana Admin (isGrafanaAdmin). Not unauthenticated RCE.
The lab
Source of truth is lab/. Image grafana/grafana:13.2.3 with GF_USERS_ALLOW_SIGN_UP=true and email verify left off. Host bind 127.0.0.1:19350. Admin creates org invite-org and invites invitee@lab.invalid as Admin. Control signup as control@lab.invalid with an empty code does not join that org. Invitee signup with the same empty code returns redirect-to-select-org. /api/user/orgs shows Admin. sqlite agrees.
git clone https://github.com/abraxas/grafana-signup-invite-join
cd grafana-signup-invite-join
python3 grafana-signup-invite-join-Abraxas-Labs.pyLast lab run, trimmed:
SUCCESS GRAFANA-SIGNUP-INVITE-JOIN version=13.2.3 invite-org=2 invite-code-len=30 step2-code-sent=empty step2-redirect=redirect-to-select-org api-role=Admin sqlite-role=Admin temp-status=Completed control-invite-org-role=none GRAFANA-SIGNUP-INVITE-JOIN-WITNESSI am not printing a signup JSON you can paste at someone else's /api/user/signup/step2. The missing code check is the useful part.
The fix
Require the pending invite code, or mailbox proof, before applyUserInvite on signup. Do not attach InvitePending rows from email match alone.
Same pin leftover: Git Sync DNS rebind. Skip Grafana G2.
References
- Proof of concept: abraxas/grafana-signup-invite-join · grafana-signup-invite-join-Abraxas-Labs.py
- Lab:
lab/· docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-862
- Tree: grafana/grafana v13.2.3 (
6193dc03311b631b9727b560d24369e683dc396e) ·signup.go·org_invite.go - Nearby (not this path): CVE-2022-39306