Proof of Concept
Poc (conceptual and for illustration only): Prior to this fix, a client-controlled header value containing control or non-ASCII bytes could be forwarded into gRPC metadata as-is, potentially enabling header injection or leakage through downstream backends/logs. The vulnerability path relies on an attacker being able to send crafted header data to a Grafana plugin endpoint which then forwards those headers to a gRPC backend.
Attack scenario (pre-fix):
1) Attacker sends a request to a Grafana plugin endpoint that uses the TracingHeaderMiddleware, setting a header such as:
- X-Dashboard-Title: dash\r\nX-Secret: leaked
2) The backend forwards raw header values into gRPC metadata or logs, allowing the injected header to appear in responses, headers, or logs, potentially leaking internal values or enabling metadata-based spoofing.
Attack scenario (post-fix with this patch):
- The malicious header value is sanitized by sanitizeHTTPHeaderValueForGRPC and isGRPCSafeHeaderValue checks, resulting in a safe, ASCII-only percent-encoded representation, e.g.:
- X-Dashboard-Title: dash%0D%0AX-Secret%3A%20leaked
- This prevents header injection effects and ensures gRPC metadata remains within printable ASCII, reducing information-disclosure/poisoning risks.
Example (conceptual, using curl-like syntax):
- Pre-fix attack (would rely on vulnerable processing): curl -H $'X-Dashboard-Title: dash\r\nX-Secret: leaked' https://grafana.example/plugins/...
- Post-fix (sanitized): the header would be encoded to an ASCII-safe form, e.g. X-Dashboard-Title: dash%0D%0AX-Secret%3A%20leaked
Note: This PoC is conceptual and demonstrates the vulnerability path and the exact remediation provided by the patch. In real deployments, the effect would depend on the downstream gRPC metadata handling and logging behavior.
Code Diff
diff --git a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go
index b8bd1f2c9d971..a8a905be40966 100644
--- a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go
+++ b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go
@@ -55,7 +55,7 @@ func (m *TracingHeaderMiddleware) applyHeaders(ctx context.Context, req backend.
if gotVal == "" {
continue
}
- if !utf8.ValidString(gotVal) {
+ if !isGRPCSafeHeaderValue(gotVal) {
gotVal = sanitizeHTTPHeaderValueForGRPC(gotVal)
}
req.SetHTTPHeader(headerName, gotVal)
@@ -111,27 +111,51 @@ func (m *TracingHeaderMiddleware) RunStream(ctx context.Context, req *backend.Ru
return m.BaseHandler.RunStream(ctx, req, sender)
}
+// isGRPCSafeHeaderValue reports whether every byte in s is a printable ASCII
+// character (0x20–0x7E), which is the range allowed in gRPC metadata values.
+func isGRPCSafeHeaderValue(s string) bool {
+ for i := 0; i < len(s); i++ {
+ b := s[i]
+ if b < 0x20 || b > 0x7E {
+ return false
+ }
+ }
+ return true
+}
+
// sanitizeHTTPHeaderValueForGRPC sanitizes header values according to HTTP/2 gRPC specification.
// The spec defines that header values must consist of printable ASCII characters 0x20 (space) - 0x7E(tilde) inclusive.
-// First attempts to decode any percent-encoded characters, then encodes invalid characters.
+//
+// If the value is already valid UTF-8, any bytes outside the printable ASCII range are
+// percent-encoded directly (e.g. é as UTF-8 0xC3 0xA9 → %C3%A9).
+//
+// If the value is NOT valid UTF-8 (e.g. a raw ISO-8859-1 byte stream as sent by some
+// browsers), it is first decoded from ISO-8859-1 to UTF-8 and then percent-encoded,
+// producing the same %C3%A9 representation for é.
func sanitizeHTTPHeaderValueForGRPC(value string) string {
- // First try to decode characters that were encoded by the frontend
- decoder := charmap.ISO8859_1.NewDecoder()
- decoded, _, err := transform.Bytes(decoder, []byte(value))
- // If decoding fails, work with the original value
- if err != nil {
- decoded = []byte(value)
+ var input []byte
+ if utf8.ValidString(value) {
+ // Already valid UTF-8: percent-encode non-printable-ASCII bytes directly.
+ input = []byte(value)
+ } else {
+ // Not valid UTF-8: assume ISO-8859-1 (Latin-1) bytes sent by the browser.
+ // Convert to UTF-8 first so the percent-encoded output is consistent.
+ decoder := charmap.ISO8859_1.NewDecoder()
+ decoded, _, err := transform.Bytes(decoder, []byte(value))
+ if err != nil {
+ decoded = []byte(value)
+ }
+ input = decoded
}
+
var sanitized strings.Builder
- sanitized.Grow(len(decoded)) // Pre-allocate reasonable capacity
- // Then encode invalid characters
- for _, b := range decoded {
+ sanitized.Grow(len(input))
+ for _, b := range input {
if b >= 0x20 && b <= 0x7E {
sanitized.WriteByte(b)
} else {
- sanitized.WriteString(fmt.Sprintf("%%%02X", b))
+ fmt.Fprintf(&sanitized, "%%%02X", b)
}
}
-
return sanitized.String()
}
diff --git a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go
index 3c3b799a35898..e5830e38a6578 100644
--- a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go
+++ b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go
@@ -239,23 +239,24 @@ func TestTracingHeaderMiddleware(t *testing.T) {
require.Equal(t, `true`, cdt.RunStreamReq.GetHTTPHeader(`X-Grafana-From-Expr`))
})
- t.Run("sanitizes grpc header values for invalid utf-8", func(t *testing.T) {
+ t.Run("sanitizes header values to printable ASCII for gRPC", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/some/thing", nil)
require.NoError(t, err)
- // Create invalid UTF-8 strings
+ // Invalid UTF-8 (raw ISO-8859-1 bytes as sent by some browsers)
invalidUTF8Dashboard := string([]byte{'d', 'a', 's', 'h', 0xFF, 0xFE, 'u', 'i', 'd'})
invalidUTF8Panel := string([]byte{'p', 'a', 'n', 'e', 'l', 0x80, 'i', 'd'})
- // Set headers with various characters that need to be sanitization
- req.Header[`X-Dashboard-Title`] = []string{invalidUTF8Dashboard} // invalid UTF-8
- req.Header[`X-Panel-Title`] = []string{invalidUTF8Panel} // invalid UTF-8
+ // All of the following contain bytes outside the gRPC printable-ASCII range (0x20–0x7E)
+ // and must be sanitized regardless of whether they are valid UTF-8.
+ req.Header[`X-Dashboard-Title`] = []string{invalidUTF8Dashboard} // invalid UTF-8: bytes 0xFF, 0xFE
+ req.Header[`X-Panel-Title`] = []string{invalidUTF8Panel} // invalid UTF-8: byte 0x80
+ req.Header[`X-Dashboard-Uid`] = []string{"dashboard\x00uid"} // null byte (0x00 < 0x20)
+ req.Header[`X-Datasource-Uid`] = []string{"datasource\tuid"} // tab (0x09 < 0x20)
+ req.Header[`X-Grafana-From-Expr`] = []string{"café résumé"} // valid UTF-8, but é = 0xC3 0xA9 (> 0x7E)
- // Set headers that don't need sanitization
- req.Header[`X-Dashboard-Uid`] = []string{"dashboard\x00uid"} // control character
- req.Header[`X-Datasource-Uid`] = []string{"datasource\tuid"} // tab character
- req.Header[`X-Query-Group-Id`] = []string{"valid-text-123"} // valid characters
- req.Header[`X-Grafana-From-Expr`] = []string{"café résumé"} // extended characters
+ // Pure printable ASCII — no sanitization needed
+ req.Header[`X-Query-Group-Id`] = []string{"valid-text-123"}
pluginCtx := backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
@@ -275,15 +276,17 @@ func TestTracingHeaderMiddleware(t *testing.T) {
})
require.NoError(t, err)
- // Invalid UTF-8 should be sanitized
+ // All non-printable-ASCII bytes are percent-encoded.
+ // Invalid UTF-8 (Latin-1 bytes) is first decoded to UTF-8, then encoded.
require.Equal(t, "dash%C3%BF%C3%BEuid", cdt.QueryDataReq.GetHTTPHeader(`X-Dashboard-Title`))
require.Equal(t, "panel%C2%80id", cdt.QueryDataReq.GetHTTPHeader(`X-Panel-Title`))
-
- // Valid characters should remain unchanged
+ // Control characters below 0x20 are percent-encoded directly.
+ require.Equal(t, "dashboard%00uid", cdt.QueryDataReq.GetHTTPHeader(`X-Dashboard-Uid`))
+ require.Equal(t, "datasource%09uid", cdt.QueryDataReq.GetHTTPHeader(`X-Datasource-Uid`))
+ // Valid UTF-8 non-ASCII bytes (> 0x7E) are percent-encoded directly.
+ require.Equal(t, "caf%C3%A9 r%C3%A9sum%C3%A9", cdt.QueryDataReq.GetHTTPHeader(`X-Grafana-From-Expr`))
+ // Pure printable ASCII is unchanged.
require.Equal(t, "valid-text-123", cdt.QueryDataReq.GetHTTPHeader(`X-Query-Group-Id`))
- require.Equal(t, "café résumé", cdt.QueryDataReq.GetHTTPHeader(`X-Grafana-From-Expr`))
- require.Equal(t, "dashboard\x00uid", cdt.QueryDataReq.GetHTTPHeader(`X-Dashboard-Uid`))
- require.Equal(t, "datasource\tuid", cdt.QueryDataReq.GetHTTPHeader(`X-Datasource-Uid`))
})
})
}