Grafana OSS, Git Sync admission-only DNS
Grafana OSS 13.2.3 Git Sync URLValidator checks the repository URL at admission. nanogit / Test re-resolve later with no IP pin. Literal 0.0.0.1 is not blocked. A public A can rebind to RFC1918. No CVE yet.
- Name
- Grafana OSS, Git Sync admission-only DNS
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Grafana Labs
- Affected
- Grafana OSS 13.2.3 (6193dc0). Git Sync on by default. Actor is Org Admin (repositories:create). Loopback, RFC1918, and IMDS are denied at admission as intended.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, grafana, ssrf, git-sync, dns-rebind, cwe-918, cwe-367, authenticated
The validator remembers the hostname. Clone does not remember the address.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/grafana-gitsync-dns-rebind (loopback client). The lab stack is lab/: Dockerfile, 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-918 / CWE-367. 7.7 High (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N). Remote SSRF. Org Admin.
Git Sync URLValidator checks the repository URL host at admission. nanogit / Test re-resolve later with no IP pin. isBlockedIP is loopback, RFC1918, link-local, and unspecified only. Literal 0.0.0.1 is not blocked. A hostname that is public at validate time can rebind to RFC1918 for the clone GET.
Loopback, RFC1918, and IMDS 169.254.169.254 are denied at admission, as intended. The leftover is the TOCTOU rebind and the 0.0.0.0/8 hole (except 0.0.0.0). This is SSRF from the Grafana process, not RCE and not a Viewer bug.
Why Grafana
I had just finished the open-signup leftover. Default OSS 13.2.3 still looked empty. Git Sync is on by default (cfg.ProvisioningEnabled = ...MustBool(true)). Org Admin already points datasources at internal URLs on OSS. URLValidator exists specifically to keep Git Sync URLs off private/loopback/link-local. If that validator is admission-only, the leftover is the validator, not "Admin can fetch."
I only wanted Critical or High. Grafana G2 (IAM SSOSetting k8s authorizer) is skip.
Pinning 13.2.3
Latest GA: v13.2.3, commit 6193dc0. Image grafana/grafana:13.2.3.
The skip list is half the hunt
Do not re-file CVE-2026-13720 (file-provisioning annotations, closed this pin) or CVE-2026-81842 (library panel dest-folder create). Import JSON cannot lift manager annotations. Editor cannot hit /admin/provisioning/*/reload. Classic FileReader and Git Sync safepath.IsSafe do not give an Editor a write-outside-data/ primitive.
Alerting webhook test is Editor receivers:writer on Grafana egress, same class as Editor DS URLs. Paused public-dashboard /bootdata/:token is Org 0 and Direct DS creds stay off. Plugin CLI GF_INSTALL_PLUGINS URL is operator-local.
CVE-2026-13719, 81841, 76154, 19197, 17033, 21723, 11817, 33382, 42129, 10601: patched. MCP X-Grafana-URL is a different repo.
Twenty surfaces, one admission-only DNS
Authn, RBAC, datasource proxy, public dashboards, alerting, plugins, XSS, rendering, Live WS, OAuth, Git Sync, query, secrets, CSRF, k8s, SCIM, patch siblings, admin files, frontend, cloud SMTP, annotations.
Most of that is Grafana doing what Grafana said it would do on 13.2.3.
Git Sync create/update of provisioning.grafana.app/v0alpha1 Repository is Org Admin (repositories:create / :write). Viewer can GET/LIST. Editor cannot create. Pull/test jobs are admin-only.
Four predicates, then a second lookup
Admission in apps/provisioning/pkg/repository/validator.go calls URLValidator.ValidateURL. Default allowed_git_urls is empty. Allowlisted hosts skip DNS entirely.
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsUnspecified()
}Unspecified is 0.0.0.0 / :: only. Remainder of 0.0.0.0/8 and CGNAT 100.64.0.0/10 pass. Tests cover literal RFC1918 and DNS-to-RFC1918. They do not cover 0.0.0.1 or 100.64.0.1.
Clone later:
client, err := nanogit.NewHTTPClient(gitConfig.URL, opts...)That re-resolves. GitHub http.Client{} does too, with no CheckRedirect. Git Smart HTTP is a GET (/info/refs?service=git-upload-pack). Test() interpolates the transport error into TestResults.Errors[].Detail. spec.*.url is stored as-is, including userinfo, and Viewer repositories:read returns it.
This is not CVE-2026-13720. That was Editor-set annotations on a dashboard. This is Org Admin-set Git URL on a Git Sync repository.
Wrong turns, in the order I made them
Treating Org Admin DS URLs as this leftover. Those are intended. This is bypass of the validator that was meant to close that class for repository URLs.
Expecting IMDS 169.254 to pass. Link-local. Blocked at admission. Lab 422.
Editor create. Denied. Actor is Org Admin.
Taking Test HTTP 400 as admission deny. Literal 0.0.0.1 is 400 transport, not 422 public-or-allowed. The validator already let it through.
A reverse shell. Theatre. The witness is Git Smart HTTP hits on the rebind sidecar.
What an attacker can do
Actor is Org Admin, not a stranger.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
| Make Grafana GET a rebinding Git URL | Org Admin POST .../repositories/{name}/test with spec.git.url=http://git.lab/... |
Recon (outbound HTTP from the Grafana process) | Lab-proven. DNS A1 public, later A RFC1918. Rebind oracle hits=3, witness in body |
Reach a literal 0.0.0.1 Git URL |
Same Test with http://0.0.0.1:18080/lab/gitsync.git |
Recon | Lab-proven not blocked at admission (status 400 transport, not 422 public-or-allowed) |
| Read Git Smart HTTP / HTTP body from that host into Test errors | gitRepository.Test interpolates transport error |
Recon | Inferred from pin. Lab used a 200 git advertisement |
| Rebind to RFC1918 / CGNAT after admission | TOCTOU: validate DNS then nanogit dial | Recon | Lab-proven for RFC1918 sidecar 172.30.60.3 |
Viewer read of stored spec.*.url including userinfo |
repositories:read |
Recon | Inferred from pin. Not this lab |
Closed on this leftover (lab-proven): literal loopback 127.0.0.1 422; RFC1918 literal 10.0.0.1 422; IMDS 169.254.169.254 422. Closed: Editor cannot create repos. Not RCE. Not Grafana Admin required.
The lab
Source of truth is lab/. Live grafana/grafana:13.2.3. Grafana DNS is the lab sidecar. Public A for git.lab is 93.184.216.34. After the first two lookups the sidecar answers 172.30.60.3 (rebind oracle). A second oracle shares the Grafana netns on :18080 for the literal 0.0.0.1 path. Host binds: Grafana 127.0.0.1:19360, DNS control 127.0.0.1:19361, rebind oracle 127.0.0.1:19362.
- Create Org Admin (not Grafana Admin).
- Test loopback / RFC1918 / IMDS URLs -> 422 blocked (3/3).
- Test
http://0.0.0.1:18080/lab/gitsync.git-> not an admission deny. - Reset DNS. Test
http://git.lab/lab/gitsync.git. Four A lookups. Rebind oracle records Git hits with the witness.
git clone https://github.com/abraxas/grafana-gitsync-dns-rebind
cd grafana-gitsync-dns-rebind
python3 grafana-gitsync-dns-rebind-Abraxas-Labs.pyLast lab run, trimmed:
SUCCESS GRAFANA-GITSYNC-DNS-REBIND how=live actor=org-admin path=rebind dns_lookups=4 negatives=3 GRAFANA-GITSYNC-DNS-REBIND-WITNESSI am not printing a Git Sync JSON you can paste at someone else's provisioning API. The missing pin is the useful part.
The fix
Pin the resolved address through clone/Test, or re-validate on every dial. Treat 0.0.0.0/8 and CGNAT as blocked. Do not skip DNS on allowlisted hosts without pinning.
Same pin leftover: SignUpStep2 invite join. Skip Grafana G2.
References
- Proof of concept: abraxas/grafana-gitsync-dns-rebind · grafana-gitsync-dns-rebind-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-918 · CWE-367
- Tree: grafana/grafana v13.2.3 (
6193dc03311b631b9727b560d24369e683dc396e) ·url_validator.go·validator.go