Header Injection / Information Exposure
Description
The commit introduces a sanitization step for headers derived from plugin rule metadata (specifically X-Rule-* headers) and propagates origin information via an X-Rule-Origin header. Before this change, plugin-derived metadata could be used to construct HTTP headers that were forwarded to data sources without stripping control characters, potentially enabling header injection or information leakage. The patch adds sanitizeHeaderValue to remove ASCII control characters (including CR and LF) and truncate values to 128 bytes, and applies it when building datasource headers. It also augments the GetEvalCondition to include an Origin metadata key (as '<origin>|<uid>') when available, and updates allowlists so X-Rule-Origin is forwarded consistently across eval paths. Overall, this is a defensive fix to prevent header manipulation and limit header sizes, reducing the risk of header-based attacks and information exposure from plugin-originated metadata.
Proof of Concept
PoC (conceptual, pre- and post-patch):
Context: Grafana forwards plugin-derived rule origin information via an HTTP header named X-Rule-Origin (as http_X-Rule-Origin in internal headers). If a malicious plugin can set origin metadata to include CR or LF characters, an attacker could attempt HTTP header injection by crafting the header value to split the downstream request and introduce arbitrary headers.
Pre-patch exploit scenario (illustrative, assumes the system forwards raw origin data into headers without sanitization):
- An alert rule originates from a plugin and the plugin sets __grafana_origin to: "plugin/malicious\r\nX-Injected: true".
- Grafana would forward a header such as: http_X-Rule-Origin: plugin/malicious\r\nX-Injected: true
- In a vulnerable server, this could result in a new header line (X-Injected: true) being parsed by the downstream service, potentially altering behavior or leaking information.
Attack vector (how to reproduce conceptually):
1) Start a test HTTP endpoint (datasource) that logs all request headers it sees.
2) Craft an HTTP request to Grafana that includes a plugin-origin value containing CRLF, for example via a plugin rule: __grafana_origin = "plugin/malicious\r\nX-Injected: true".
3) Have Grafana forward the request to the datasource. Observe that the downstream server logs both the intended header and the injected header (X-Injected: true).
Post-patch scenario (effective mitigation):
- sanitizeHeaderValue strips CR, LF and other ASCII control characters, and truncates to 128 bytes before encoding into the header.
- Example sanitized value from the above input would be: "plugin/malicious" (CRLF removed, length capped).
- The header line becomes: http_X-Rule-Origin: plugin/malicious (no injected headers).
Notes:
- The test suite added in this patch includes TestSanitizeHeaderValue to validate removal of control characters and truncation behavior.
- X-Rule-Origin is now included in the header allowlists so it’s forwarded in all eval paths, ensuring consistent behavior across the system.
Commit Details
Author: Yuri Tseretyan
Date: 2026-06-03 17:23 UTC
Message:
Alerting: Propagate plugin rule origin as X-Rule-Origin header (#125206)
* Alerting: propagate plugin rule origin as X-Rule-Origin header
Rules that originate from a plugin (identified by the __grafana_origin
label) now include that value as an X-Rule-Origin header in datasource
eval requests. GetEvalCondition populates the Origin metadata key;
buildDatasourceHeaders sanitizes all metadata values (strips control
characters, truncates to 128 bytes) before encoding them as headers.
X-Rule-Origin is registered in both the query-API and plugin-middleware
header allowlists so it is forwarded correctly in all eval paths.
* Alerting: add __grafana_origin_uid label to X-Rule-Origin header
Introduce PluginGrafanaOriginUIDLabel (__grafana_origin_uid) as a
standard label plugins can set to identify the specific resource that
owns an alert rule. When present alongside __grafana_origin, the two
values are combined into a single X-Rule-Origin header as
"<origin>|<uid>" (e.g. "plugin/grafana-slo-app|abc-123").
For the SLO plugin, which cannot yet set __grafana_origin_uid, a
fallback reads grafana_slo_uuid when the origin is
"plugin/grafana-slo-app". The generic label takes precedence when both
are present.
Triage Assessment
Vulnerability Type: Header Injection / Information Exposure
Confidence: HIGH
Reasoning:
The commit introduces sanitizeHeaderValue to strip control characters and truncate header values before encoding them into HTTP headers, and applies it to metadata-derived headers (X-Rule-*) used in eval requests. This mitigates header injection and prevents oversized headers, reducing risks of information leakage or HTTP response/request manipulation. It also propagates origin metadata securely.
Verification Assessment
Vulnerability Type: Header Injection / Information Exposure
Confidence: HIGH
Affected Versions: Grafana <= 12.4.0 (prior to this patch)
Code Diff
diff --git a/pkg/registry/apis/query/header_utils.go b/pkg/registry/apis/query/header_utils.go
index 4a2890c878e9a..2ac353e4b2bb0 100644
--- a/pkg/registry/apis/query/header_utils.go
+++ b/pkg/registry/apis/query/header_utils.go
@@ -34,6 +34,7 @@ var expectedHeaders = map[string]string{
strings.ToLower("X-Rule-Source"): "X-Rule-Source",
strings.ToLower("X-Rule-Type"): "X-Rule-Type",
strings.ToLower("X-Rule-Version"): "X-Rule-Version",
+ strings.ToLower("X-Rule-Origin"): "X-Rule-Origin",
strings.ToLower("X-Grafana-Org-Id"): "X-Grafana-Org-Id",
strings.ToLower(queryService.HeaderQueryGroupID): queryService.HeaderQueryGroupID,
strings.ToLower(queryService.HeaderPanelID): queryService.HeaderPanelID,
diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go
index 39559d6489eea..ebbfe22954a0c 100644
--- a/pkg/services/ngalert/eval/eval.go
+++ b/pkg/services/ngalert/eval/eval.go
@@ -331,12 +331,28 @@ func ParseStateString(repr string) (State, error) {
}
}
+// sanitizeHeaderValue strips ASCII control characters (including CRLF) and
+// truncates to 128 bytes to prevent header injection and oversized headers.
+func sanitizeHeaderValue(v string) string {
+ s := strings.Map(func(r rune) rune {
+ if r < 0x20 || r == 0x7f {
+ return -1
+ }
+ return r
+ }, v)
+ const maxLen = 128
+ if len(s) > maxLen {
+ s = s[:maxLen]
+ }
+ return s
+}
+
func buildDatasourceHeaders(ctx context.Context, metadata map[string]string) map[string]string {
headers := make(map[string]string, len(metadata)+3)
if len(metadata) > 0 {
for key, value := range metadata {
- headers[fmt.Sprintf("http_X-Rule-%s", key)] = url.QueryEscape(value)
+ headers[fmt.Sprintf("http_X-Rule-%s", key)] = url.QueryEscape(sanitizeHeaderValue(value))
}
}
diff --git a/pkg/services/ngalert/eval/eval_test.go b/pkg/services/ngalert/eval/eval_test.go
index 33665f1f638ff..2c71c7d47a6f0 100644
--- a/pkg/services/ngalert/eval/eval_test.go
+++ b/pkg/services/ngalert/eval/eval_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"strconv"
+ "strings"
"testing"
"time"
@@ -1614,3 +1615,58 @@ func (f fakeNode) SetInputTo(a string) {
func (f fakeNode) DisabledErr() error {
return nil
}
+
+func TestSanitizeHeaderValue(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "plain value passes through unchanged",
+ input: "hello-world",
+ expected: "hello-world",
+ },
+ {
+ name: "CR is stripped",
+ input: "foo\rbar",
+ expected: "foobar",
+ },
+ {
+ name: "LF is stripped",
+ input: "foo\nbar",
+ expected: "foobar",
+ },
+ {
+ name: "CRLF sequence is stripped",
+ input: "foo\r\nbar",
+ expected: "foobar",
+ },
+ {
+ name: "other ASCII control characters are stripped",
+ input: "foo\x00\x01\x1f\x7fbar",
+ expected: "foobar",
+ },
+ {
+ name: "value exactly 128 bytes is not truncated",
+ input: strings.Repeat("a", 128),
+ expected: strings.Repeat("a", 128),
+ },
+ {
+ name: "value longer than 128 bytes is truncated",
+ input: strings.Repeat("a", 200),
+ expected: strings.Repeat("a", 128),
+ },
+ {
+ name: "control chars removed before truncation check",
+ input: strings.Repeat("a", 100) + "\r\n" + strings.Repeat("b", 100),
+ expected: strings.Repeat("a", 100) + strings.Repeat("b", 28),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.expected, sanitizeHeaderValue(tt.input))
+ })
+ }
+}
diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go
index 10f9612314b26..34077e7d2fd89 100644
--- a/pkg/services/ngalert/models/alert_rule.go
+++ b/pkg/services/ngalert/models/alert_rule.go
@@ -174,6 +174,17 @@ const (
// PluginGrafanaOriginLabel is a label that indicates that the alert rule originated from a plugin.
PluginGrafanaOriginLabel = "__grafana_origin"
+ // PluginGrafanaOriginUIDLabel is an optional label carrying the UID of the resource within the
+ // origin plugin that owns this rule. When present it is appended to the X-Rule-Origin header
+ // as "<origin>|<uid>". Plugins that cannot set this label may use a plugin-specific fallback
+ // (see PluginGrafanaSLOUUIDLabel).
+ PluginGrafanaOriginUIDLabel = "__grafana_origin_uid"
+
+ // PluginGrafanaSLOOrigin is the __grafana_origin value set by the Grafana SLO plugin.
+ PluginGrafanaSLOOrigin = "plugin/grafana-slo-app"
+ // PluginGrafanaSLOUUIDLabel is the SLO-specific label used as a fallback source for the
+ // resource UID when PluginGrafanaOriginUIDLabel is absent.
+ PluginGrafanaSLOUUIDLabel = "grafana_slo_uuid"
)
const (
@@ -582,6 +593,17 @@ func (alertRule *AlertRule) GetEvalCondition() Condition {
"Type": string(alertRule.Type()),
"Version": strconv.FormatInt(alertRule.Version, 10),
}
+ if origin := alertRule.Labels[PluginGrafanaOriginLabel]; origin != "" {
+ uid := alertRule.Labels[PluginGrafanaOriginUIDLabel]
+ if uid == "" && origin == PluginGrafanaSLOOrigin {
+ uid = alertRule.Labels[PluginGrafanaSLOUUIDLabel]
+ }
+ if uid != "" {
+ meta["Origin"] = origin + "|" + uid
+ } else {
+ meta["Origin"] = origin
+ }
+ }
if alertRule.Type() == RuleTypeRecording {
return Condition{
Metadata: meta,
diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go
index 1ff0a12e6d7c2..3a49e8d1d08ae 100644
--- a/pkg/services/ngalert/models/alert_rule_test.go
+++ b/pkg/services/ngalert/models/alert_rule_test.go
@@ -1488,3 +1488,71 @@ func TestWithoutPrivateLabels(t *testing.T) {
})
}
}
+
+func TestAlertRuleGetEvalCondition_Origin(t *testing.T) {
+ const sloOrigin = PluginGrafanaSLOOrigin
+ const otherOrigin = "plugin/grafana-other-app"
+
+ tests := []struct {
+ name string
+ labels map[string]string
+ expectedOrigin string // empty means key should be absent
+ }{
+ {
+ name: "no origin label",
+ labels: map[string]string{},
+ expectedOrigin: "",
+ },
+ {
+ name: "origin only",
+ labels: map[string]string{PluginGrafanaOriginLabel: otherOrigin},
+ expectedOrigin: otherOrigin,
+ },
+ {
+ name: "origin with generic uid label",
+ labels: map[string]string{
+ PluginGrafanaOriginLabel: otherOrigin,
+ PluginGrafanaOriginUIDLabel: "generic-uid",
+ },
+ expectedOrigin: otherOrigin + "|generic-uid",
+ },
+ {
+ name: "SLO origin with slo uuid fallback",
+ labels: map[string]string{
+ PluginGrafanaOriginLabel: sloOrigin,
+ PluginGrafanaSLOUUIDLabel: "slo-uuid-123",
+ },
+ expectedOrigin: sloOrigin + "|slo-uuid-123",
+ },
+ {
+ name: "SLO origin with both labels — generic takes precedence",
+ labels: map[string]string{
+ PluginGrafanaOriginLabel: sloOrigin,
+ PluginGrafanaOriginUIDLabel: "generic-uid",
+ PluginGrafanaSLOUUIDLabel: "slo-uuid-123",
+ },
+ expectedOrigin: sloOrigin + "|generic-uid",
+ },
+ {
+ name: "non-SLO origin with slo uuid label — fallback not triggered",
+ labels: map[string]string{
+ PluginGrafanaOriginLabel: otherOrigin,
+ PluginGrafanaSLOUUIDLabel: "slo-uuid-123",
+ },
+ expectedOrigin: otherOrigin,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rule := RuleGen.With(RuleMuts.WithLabels(tt.labels)).Generate()
+ condition := rule.GetEvalCondition()
+
+ if tt.expectedOrigin == "" {
+ require.NotContains(t, condition.Metadata, "Origin")
+ } else {
+ require.Equal(t, tt.expectedOrigin, condition.Metadata["Origin"])
+ }
+ })
+ }
+}
diff --git a/pkg/services/pluginsintegration/clientmiddleware/usealertingheaders_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/usealertingheaders_middleware.go
index 59e12d9717e44..87b830d6deb69 100644
--- a/pkg/services/pluginsintegration/clientmiddleware/usealertingheaders_middleware.go
+++ b/pkg/services/pluginsintegration/clientmiddleware/usealertingheaders_middleware.go
@@ -27,6 +27,7 @@ var alertHeaders = []string{
"X-Rule-Source",
"X-Rule-Type",
"X-Rule-Version",
+ "X-Rule-Origin",
ngalertmodels.FromAlertHeaderName,
}