Information disclosure / header injection risk via improper HTTP/2 gRPC metadata header sanitization

HIGH
grafana/grafana
Commit: a878d6650d7c
Affected: Grafana 12.4.0 (and likely earlier in 12.4.x line; this commit targets 12.4.0 tracked release)
2026-04-10 20:04 UTC

Description

The commit adds explicit sanitization of HTTP header values used for gRPC metadata in the Grafana plugins integration path. It introduces: (1) isGRPCSafeHeaderValue to check that header bytes are printable ASCII (0x20 to 0x7E), and (2) sanitizeHTTPHeaderValueForGRPC to convert header values to a gRPC-safe representation by percent-encoding unsafe bytes. If the value is valid UTF-8, non-printable ASCII bytes are percent-encoded directly; if not valid UTF-8, the value is treated as ISO-8859-1, decoded to UTF-8, and then percent-encoded. Headers are then set with sanitized values. The tests demonstrate expected sanitization behavior, ensuring gRPC metadata remains ASCII-printable and preventing problematic header content from propagating to downstream services.

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.

Commit Details

Author: Adam Yeats

Date: 2026-04-10 19:16 UTC

Message:

Plugins: Sanitise header values to printable ASCII for gRPC compatibility (#122237) Plugins: sanitize header values to printable ASCII for gRPC compatibility

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: HIGH

Reasoning:

The commit adds sanitization of HTTP header values to ensure they are printable ASCII for gRPC compatibility, including a function to detect non-ASCII content and to percent-encode unsafe bytes. This is input validation/sanitization of headers to prevent malformed or potentially exploitable header data in gRPC metadata, reducing risks of header-based issues or information leakage. Tests reflect expected sanitization behavior.

Verification Assessment

Vulnerability Type: Information disclosure / header injection risk via improper HTTP/2 gRPC metadata header sanitization

Confidence: HIGH

Affected Versions: Grafana 12.4.0 (and likely earlier in 12.4.x line; this commit targets 12.4.0 tracked release)

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`)) }) }) }
← Back to Alerts View on GitHub →