Input validation / Authorization bypass
Description
The commit adds explicit repository-origin validation for GitHub webhook events in the provisioning subsystem. Previously, webhook events (e.g., push or pull_request) could be processed without verifying that the event's repository matched the configured Grafana provisioning repository (owner/repo). The fix introduces a repository mismatch check and returns a BadRequest with a repository mismatch error when the event's repository does not match the expected one. It also logs warnings when mismatches occur and propagates a dedicated ErrRepositoryMismatch error. This reduces the risk of unauthorized provisioning actions triggered by spoofed or misrouted webhook payloads.
Proof of Concept
PoC (proof-of-concept) to reproduce the vulnerability prior to the fix: 1) Assumptions: You control a GitHub repository and Grafana is configured to receive GitHub webhooks for provisioning, with a known webhook secret (used to compute the X-Hub-Signature-256). The Grafana instance is configured to provision resources for owner/repo = grafana/grafana. 2) Build a webhook payload that mimics a GitHub push event from a different repository, e.g., repository.full_name = evil/repo. The rest of the payload can be a minimal valid push event payload. 3) Compute a valid HMAC-SHA256 signature over the payload using the known webhook secret and set the header X-Hub-Signature-256: sha256=<signature>. 4) Send the webhook to Grafana's provisioning endpoint (e.g., POST https://grafana.example.com/api/provisioning/github/webhook) with headers: Content-Type: application/json, X-GitHub-Event: push, X-Hub-Signature-256: sha256=<signature>. 5) Observe that the request is accepted by the webhook handler only if the repository matches Grafana's configured owner/repo; otherwise, the server should reject with a 400 Bad Request indicating repository mismatch. 6) Expected outcome after the fix: Grafana responds with a 400 Bad Request and a repository mismatch error when the payload contains a non-matching repository. Prerequisites: access to Grafana webhook secret, a reachable provisioning webhook endpoint, and a payload containing a mismatched repository full_name. Code example (Python to generate signature and curl payload):
import json
import hmac
import hashlib
import requests
secret = b'YOUR_WEBHOOK_SECRET' # replace with actual Grafana webhook secret
payload = {
"repository": {"full_name": "evil/repo"},
"ref": "refs/heads/main",
"after": "0000000000000000000000000000000000000000"
}
body = json.dumps(payload, separators=(",", ":"))
signature = 'sha256=' + hmac.new(secret, body.encode(), hashlib.sha256).hexdigest()
url = 'https://grafana.example.com/api/provisioning/github/webhook' # endpoint used for provisioning
headers = {
'Content-Type': 'application/json',
'X-GitHub-Event': 'push',
'X-Hub-Signature-256': signature
}
resp = requests.post(url, data=body, headers=headers, verify=False)
print(resp.status_code, resp.text)
Notes:
- If the vulnerability is present (pre-fix), Grafana may process the event for the Grafana repo despite the mismatched repository field, potentially triggering provisioning actions. After the fix, the endpoint should reject with repository mismatch (HTTP 400) when the repository does not match the configured owner/repo.
Commit Details
Author: Daniele Stefano Ferru
Date: 2026-05-08 10:04 UTC
Message:
Provisioning: Return Bad request for repo mismatch in webhook (#124453)
* Provisioning: Return Bad request for repo mismatch in webhook
* linting
* addressing comment
Triage Assessment
Vulnerability Type: Input validation / Authorization bypass
Confidence: HIGH
Reasoning:
The commit adds explicit validation to ensure webhook events originate from the expected repository and returns a BadRequest / repository mismatch error when they don't. This prevents processing of webhooks from unintended repos, reducing the risk of unauthorized actions triggered by spoofed or mismatched webhook payloads.
Verification Assessment
Vulnerability Type: Input validation / Authorization bypass
Confidence: HIGH
Affected Versions: <= 12.3.x (Grafana versions prior to this patch); 12.4.0 includes the fix
Code Diff
diff --git a/apps/provisioning/pkg/repository/github/webhook.go b/apps/provisioning/pkg/repository/github/webhook.go
index cbb1a56fe3e70..cb953c22be3e7 100644
--- a/apps/provisioning/pkg/repository/github/webhook.go
+++ b/apps/provisioning/pkg/repository/github/webhook.go
@@ -76,11 +76,11 @@ func (r *githubWebhookRepository) Webhook(ctx context.Context, req *http.Request
return nil, apierrors.NewUnauthorized("invalid signature")
}
- return r.parseWebhook(github.WebHookType(req), payload)
+ return r.parseWebhook(ctx, github.WebHookType(req), payload)
}
// This method does not include context because it does delegate any more requests
-func (r *githubWebhookRepository) parseWebhook(messageType string, payload []byte) (*provisioning.WebhookResponse, error) {
+func (r *githubWebhookRepository) parseWebhook(ctx context.Context, messageType string, payload []byte) (*provisioning.WebhookResponse, error) {
event, err := github.ParseWebHook(messageType, payload)
if err != nil {
return nil, apierrors.NewBadRequest("invalid payload")
@@ -88,7 +88,7 @@ func (r *githubWebhookRepository) parseWebhook(messageType string, payload []byt
switch event := event.(type) {
case *github.PushEvent:
- return r.parsePushEvent(event)
+ return r.parsePushEvent(ctx, event)
case *github.PullRequestEvent:
return r.parsePullRequestEvent(event)
case *github.PingEvent:
@@ -104,12 +104,16 @@ func (r *githubWebhookRepository) parseWebhook(messageType string, payload []byt
}
}
-func (r *githubWebhookRepository) parsePushEvent(event *github.PushEvent) (*provisioning.WebhookResponse, error) {
+func (r *githubWebhookRepository) parsePushEvent(ctx context.Context, event *github.PushEvent) (*provisioning.WebhookResponse, error) {
+ _, logger := r.logger(ctx, "")
+
if event.GetRepo() == nil {
return nil, fmt.Errorf("missing repository in push event")
}
- if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) {
- return nil, fmt.Errorf("repository mismatch")
+ expected := fmt.Sprintf("%s/%s", r.owner, r.repo)
+ if event.GetRepo().GetFullName() != expected {
+ logger.Warn("webhook push event repository mismatch", "expected", expected, "got", event.GetRepo().GetFullName())
+ return nil, repository.ErrRepositoryMismatch
}
// No need to sync if not enabled
@@ -153,8 +157,10 @@ func (r *githubWebhookRepository) parsePullRequestEvent(event *github.PullReques
return nil, fmt.Errorf("missing GitHub config")
}
- if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) {
- return nil, fmt.Errorf("repository mismatch")
+ expected := fmt.Sprintf("%s/%s", r.owner, r.repo)
+ if event.GetRepo().GetFullName() != expected {
+ slog.Warn("webhook pull request event repository mismatch", "expected", expected, "got", event.GetRepo().GetFullName())
+ return nil, repository.ErrRepositoryMismatch
}
pr := event.GetPullRequest()
if pr == nil {
diff --git a/apps/provisioning/pkg/repository/github/webhook_test.go b/apps/provisioning/pkg/repository/github/webhook_test.go
index ea682d20747ba..b12fe8ba3e2a8 100644
--- a/apps/provisioning/pkg/repository/github/webhook_test.go
+++ b/apps/provisioning/pkg/repository/github/webhook_test.go
@@ -133,7 +133,7 @@ func TestParseWebhooks(t *testing.T) {
payload, err := os.ReadFile(path.Join("testdata", name))
require.NoError(t, err)
- rsp, err := gh.parseWebhook(tt.messageType, payload)
+ rsp, err := gh.parseWebhook(context.Background(), tt.messageType, payload)
require.NoError(t, err)
require.Equal(t, tt.expected.Code, rsp.Code)
@@ -167,7 +167,7 @@ func TestParsePushEvent_LargeDiffForcesFullSync(t *testing.T) {
payload, err := os.ReadFile(path.Join("testdata", "webhook-push-large_diff.json"))
require.NoError(t, err)
- rsp, err := gh.parseWebhook("push", payload)
+ rsp, err := gh.parseWebhook(context.Background(), "push", payload)
require.NoError(t, err)
require.Equal(t, http.StatusAccepted, rsp.Code)
@@ -400,7 +400,7 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
- expectedError: fmt.Errorf("repository mismatch"),
+ expectedError: repo.ErrRepositoryMismatch,
},
{
name: "push event when sync is disabled",
@@ -779,7 +779,7 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
- expectedError: fmt.Errorf("repository mismatch"),
+ expectedError: repo.ErrRepositoryMismatch,
},
{
name: "pull request event missing pull request info",
diff --git a/apps/provisioning/pkg/repository/repository.go b/apps/provisioning/pkg/repository/repository.go
index 73c16d48e9b1a..4020984fece93 100644
--- a/apps/provisioning/pkg/repository/repository.go
+++ b/apps/provisioning/pkg/repository/repository.go
@@ -76,6 +76,8 @@ var ErrTooManyItems error = &apierrors.StatusError{ErrStatus: metav1.Status{
Message: "maximum number of items exceeded",
}}
+var ErrRepositoryMismatch = apierrors.NewBadRequest("repository mismatch")
+
type FileInfo struct {
// Path to the file on disk.
// No leading or trailing slashes will be contained within.