Information disclosure

MEDIUM
grafana/grafana
Commit: 70c85a73cba1
Affected: <= 12.4.0
2026-05-28 13:31 UTC

Description

The commit hardens error handling in the dash validator and related Prometheus fetcher to prevent information leakage via user-facing error messages and logs. Previously, upstream error responses could include sensitive details such as the datasource URL and raw response bodies (e.g., Prometheus error messages or internal stack traces). The changes remove or mask these details from user-facing errors (e.g., omitting URL and response bodies in validation errors) and log internal data at DEBUG level for operators. This reduces information disclosure vectors (URLs, error payloads) while preserving enough context for debugging via internal logs.

Proof of Concept

PoC demonstration (conceptual): Environment: Grafana 12.4.x with a Prometheus datasource that requires authentication. 1) Configure a Prometheus datasource (datasourceUID: ds-prom) pointing to a protected Prometheus instance. 2) Trigger a dashboard validator/compatibility check that queries the Prometheus API via the dashvalidator (e.g., POST to the relevant internal endpoint with datasourceUID and datasourceURL). 3) Cause the upstream Prometheus to return an error (e.g., 401 Unauthorized) with a raw body containing sensitive information (HTTP 401 with body like: {"error":"invalid credentials","details":"Secret path /internal/..."}). Before the fix (what an attacker could observe): - The Grafana API response includes the upstream raw body or the upstream URL in the error payload, e.g.: { "message": "Prometheus API returned status 401", "details": {"url": "http://prometheus.internal:9090/api/v1/series", "responseBody": "{...internal stack/secret...}"} } - An attacker could glean internal endpoints, error details, or secrets from the response body. After the fix (what an attacker will observe): - The user-facing error omits sensitive bits and only includes high-level context, e.g.: { "message": "Prometheus API returned status 401", "details": {"datasourceUID": "ds-prom"} } - Internal, sensitive data are logged server-side at DEBUG level (not exposed to clients). Prerequisites/conditions: - A Grafana instance with the dashvalidator and Prometheus fetcher enabled. - A Prometheus datasource requiring authentication that returns error body content on failure. - Access to the Grafana API endpoint that triggers the dashvalidator check.

Commit Details

Author: Alexa Vargas

Date: 2026-05-28 12:38 UTC

Message:

Suggested Dashboards: `dashboardValidatorApp` improve error response (#125526) * Suggested Dashboards: dashbboardValidatorApp improve error responses * apply pr feedback and refactor logging logic

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The commit hides internal details (such as URLs and raw response bodies) from user-facing error messages and uses internal logging/debug traces. This reduces information leakage to clients and potential attackers, addressing information disclosure and error handling security concerns.

Verification Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Affected Versions: <= 12.4.0

Code Diff

diff --git a/apps/dashvalidator/pkg/app/app.go b/apps/dashvalidator/pkg/app/app.go index 79f4e0ffdbb7..17e0efb1b795 100644 --- a/apps/dashvalidator/pkg/app/app.go +++ b/apps/dashvalidator/pkg/app/app.go @@ -136,6 +136,7 @@ func handleCheckRoute( defer cancel() logger := log.WithContext(ctx) + ctx = logging.Context(ctx, logger) logger.Info("Received compatibility check request") // Step 1: Parse request body @@ -215,6 +216,7 @@ func handleCheckRoute( ) } logger = logger.With("orgID", orgID, "namespace", namespace) + ctx = logging.Context(ctx, logger) // Extract the requester once for per-datasource scoped permission checks user, err := identity.GetRequester(ctx) diff --git a/apps/dashvalidator/pkg/validator/errors.go b/apps/dashvalidator/pkg/validator/errors.go index 77cbea8cecae..e4a84111198c 100644 --- a/apps/dashvalidator/pkg/validator/errors.go +++ b/apps/dashvalidator/pkg/validator/errors.go @@ -98,25 +98,25 @@ func NewDatasourceWrongTypeError(uid string, expectedType string, actualType str WithDetail("actualType", actualType) } -// NewDatasourceUnreachableError creates an error for unreachable datasource -func NewDatasourceUnreachableError(uid string, url string, cause error) *ValidationError { +// NewDatasourceUnreachableError creates an error for unreachable datasource. +// URL is intentionally omitted from the user-facing error; operators recover +// it from DEBUG logs at the caller site. +func NewDatasourceUnreachableError(uid string, cause error) *ValidationError { return NewValidationError( ErrCodeDatasourceUnreachable, - fmt.Sprintf("datasource %s at %s is unreachable", uid, url), + fmt.Sprintf("datasource %s is unreachable", uid), http.StatusServiceUnavailable, ).WithDetail("datasourceUID", uid). - WithDetail("url", url). WithCause(cause) } // NewAPIUnavailableError creates an error for unavailable API -func NewAPIUnavailableError(statusCode int, responseBody string, cause error) *ValidationError { +func NewAPIUnavailableError(statusCode int, cause error) *ValidationError { return NewValidationError( ErrCodeAPIUnavailable, fmt.Sprintf("Prometheus API returned status %d", statusCode), http.StatusBadGateway, ).WithDetail("upstreamStatus", statusCode). - WithDetail("responseBody", responseBody). WithCause(cause) } @@ -129,13 +129,15 @@ func NewAPIInvalidResponseError(message string, cause error) *ValidationError { ).WithCause(cause) } -// NewAPITimeoutError creates an error for API timeout -func NewAPITimeoutError(url string, cause error) *ValidationError { +// NewAPITimeoutError creates an error for API timeout. +// URL is intentionally omitted from the user-facing error; operators recover +// it from DEBUG logs at the caller site. +func NewAPITimeoutError(uid string, cause error) *ValidationError { return NewValidationError( ErrCodeAPITimeout, - fmt.Sprintf("request to %s timed out", url), + fmt.Sprintf("request to datasource %s timed out", uid), http.StatusGatewayTimeout, - ).WithDetail("url", url). + ).WithDetail("datasourceUID", uid). WithCause(cause) } diff --git a/apps/dashvalidator/pkg/validator/prometheus/fetcher.go b/apps/dashvalidator/pkg/validator/prometheus/fetcher.go index e72d664be092..bcfdc32ae79a 100644 --- a/apps/dashvalidator/pkg/validator/prometheus/fetcher.go +++ b/apps/dashvalidator/pkg/validator/prometheus/fetcher.go @@ -11,6 +11,8 @@ import ( "path" "strings" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/apps/dashvalidator/pkg/validator" ) @@ -32,15 +34,16 @@ type prometheusResponse struct { // FetchMetrics queries Prometheus to get all available metric names // It uses the /api/v1/label/__name__/values endpoint // The provided HTTP client should have proper authentication configured -func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceURL string, client *http.Client) ([]string, error) { +func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceUID, datasourceURL string, client *http.Client) ([]string, error) { // Build the API URL baseURL, err := url.Parse(datasourceURL) if err != nil { + logging.FromContext(ctx).Debug("invalid datasource URL", "datasourceUID", datasourceUID, "url", datasourceURL, "error", err) return nil, validator.NewValidationError( validator.ErrCodeDatasourceConfig, "invalid datasource URL", http.StatusBadRequest, - ).WithCause(err).WithDetail("url", datasourceURL) + ).WithCause(err).WithDetail("datasourceUID", datasourceUID) } // Append Prometheus API endpoint to base URL path using path.Join @@ -61,12 +64,13 @@ func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceURL string, client // Execute the request using the provided authenticated client resp, err := client.Do(req) if err != nil { + logging.FromContext(ctx).Debug("upstream request failed", "datasourceUID", datasourceUID, "url", baseURL.String(), "error", err) // Check if it's a timeout error if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout") { - return nil, validator.NewAPITimeoutError(baseURL.String(), err) + return nil, validator.NewAPITimeoutError(datasourceUID, err) } // Network or connection error - datasource is unreachable - return nil, validator.NewDatasourceUnreachableError("", datasourceURL, err) + return nil, validator.NewDatasourceUnreachableError(datasourceUID, err) } defer func() { _ = resp.Body.Close() }() @@ -76,46 +80,49 @@ func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceURL string, client body = []byte("<unable to read response body>") } + // Log upstream body once at DEBUG for any non-success status + if resp.StatusCode != http.StatusOK { + logging.FromContext(ctx).Debug("upstream response body", "datasourceUID", datasourceUID, "url", baseURL.String(), "statusCode", resp.StatusCode, "body", string(body)) + } + // Check HTTP status code switch resp.StatusCode { case http.StatusOK: // Success - continue to parse response case http.StatusUnauthorized, http.StatusForbidden: // Authentication or authorization failure - return nil, validator.NewDatasourceAuthError("", resp.StatusCode). - WithDetail("url", baseURL.String()). - WithDetail("responseBody", string(body)) + return nil, validator.NewDatasourceAuthError(datasourceUID, resp.StatusCode) case http.StatusNotFound: // Endpoint not found - might not be a valid Prometheus instance return nil, validator.NewAPIUnavailableError( resp.StatusCode, - string(body), fmt.Errorf("endpoint not found - this may not be a valid Prometheus datasource"), - ).WithDetail("url", baseURL.String()) + ).WithDetail("datasourceUID", datasourceUID) case http.StatusTooManyRequests: // Rate limiting return nil, validator.NewValidationError( validator.ErrCodeAPIRateLimit, "Prometheus API rate limit exceeded", http.StatusTooManyRequests, - ).WithDetail("url", baseURL.String()).WithDetail("responseBody", string(body)) + ).WithDetail("datasourceUID", datasourceUID) case http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusGatewayTimeout: // Upstream service is down or unavailable - return nil, validator.NewAPIUnavailableError(resp.StatusCode, string(body), nil). - WithDetail("url", baseURL.String()) + return nil, validator.NewAPIUnavailableError(resp.StatusCode, nil). + WithDetail("datasourceUID", datasourceUID) default: // Other error status codes - return nil, validator.NewAPIUnavailableError(resp.StatusCode, string(body), nil). - WithDetail("url", baseURL.String()) + return nil, validator.NewAPIUnavailableError(resp.StatusCode, nil). + WithDetail("datasourceUID", datasourceUID) } // Parse the response JSON var promResp prometheusResponse if err := json.Unmarshal(body, &promResp); err != nil { + logging.FromContext(ctx).Debug("upstream response body", "datasourceUID", datasourceUID, "url", baseURL.String(), "statusCode", resp.StatusCode, "body", string(body)) return nil, validator.NewAPIInvalidResponseError( "response is not valid JSON", err, - ).WithDetail("url", baseURL.String()).WithDetail("responseBody", string(body)) + ).WithDetail("datasourceUID", datasourceUID) } // Check Prometheus API status field @@ -124,18 +131,20 @@ func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceURL string, client if errorMsg == "" { errorMsg = "unknown error" } + logging.FromContext(ctx).Debug("upstream returned error status", "datasourceUID", datasourceUID, "url", baseURL.String(), "prometheusError", errorMsg) return nil, validator.NewAPIInvalidResponseError( fmt.Sprintf("Prometheus API returned error status: %s", errorMsg), nil, - ).WithDetail("url", baseURL.String()).WithDetail("prometheusError", errorMsg) + ).WithDetail("datasourceUID", datasourceUID).WithDetail("prometheusError", errorMsg) } // Validate that we got data if promResp.Data == nil { + logging.FromContext(ctx).Debug("upstream response body", "datasourceUID", datasourceUID, "url", baseURL.String(), "statusCode", resp.StatusCode, "body", string(body)) return nil, validator.NewAPIInvalidResponseError( "response missing 'data' field", nil, - ).WithDetail("url", baseURL.String()).WithDetail("responseBody", string(body)) + ).WithDetail("datasourceUID", datasourceUID) } return promResp.Data, nil diff --git a/apps/dashvalidator/pkg/validator/prometheus/fetcher_test.go b/apps/dashvalidator/pkg/validator/prometheus/fetcher_test.go index f18847f423fd..0b5897d8a4bf 100644 --- a/apps/dashvalidator/pkg/validator/prometheus/fetcher_test.go +++ b/apps/dashvalidator/pkg/validator/prometheus/fetcher_test.go @@ -36,7 +36,7 @@ func TestFetchMetrics_Success_ReturnsMetrics(t *testing.T) { // Execute fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client()) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", server.URL, server.Client()) // Verify require.NoError(t, err) @@ -62,7 +62,7 @@ func TestFetchMetrics_Success_URLWithPath(t *testing.T) { // Execute with path suffix fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(context.Background(), server.URL+"/api/prom", server.Client()) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", server.URL+"/api/prom", server.Client()) // Verify require.NoError(t, err) @@ -95,7 +95,7 @@ func TestFetchMetrics_InvalidURL_ReturnsConfigError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(context.Background(), tt.url, &http.Client{}) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", tt.url, &http.Client{}) // Verify error require.Error(t, err) @@ -114,7 +114,7 @@ func TestFetchMetrics_EmptyURL_ReturnsNetworkError(t *testing.T) { // Note: An empty URL is technically parseable by Go's url.Parse // but results in a network error when trying to make the request fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(context.Background(), "", &http.Client{}) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", "", &http.Client{}) // Verify error - empty URL fails at network level, not URL parsing require.Error(t, err) @@ -134,7 +134,7 @@ func TestFetchMetrics_ConnectionRefused_ReturnsUnreachableError(t *testing.T) { fetcher := NewFetcher() client := &http.Client{Timeout: 100 * time.Millisecond} - metrics, err := fetcher.FetchMetrics(context.Background(), "http://127.0.0.1:1", client) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", "http://127.0.0.1:1", client) // Verify error require.Error(t, err) @@ -160,7 +160,7 @@ func TestFetchMetrics_ContextCancelled_ReturnsError(t *testing.T) { cancel() fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(ctx, server.URL, server.Client()) + metrics, err := fetcher.FetchMetrics(ctx, "test-ds-uid", server.URL, server.Client()) // Verify error - context cancellation returns unreachable error require.Error(t, err) @@ -187,7 +187,7 @@ func TestFetchMetrics_HTTPClientTimeout_ReturnsTimeoutError(t *testing.T) { } fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, client) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", server.URL, client) // Verify timeout error is returned require.Error(t, err) @@ -214,7 +214,7 @@ func TestFetchMetrics_DeadlineExceeded_ReturnsTimeoutError(t *testing.T) { defer cancel() fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(ctx, server.URL, server.Client()) + metrics, err := fetcher.FetchMetrics(ctx, "test-ds-uid", server.URL, server.Client()) // Verify error require.Error(t, err) @@ -306,7 +306,7 @@ func TestFetchMetrics_HTTPStatusCodes_ReturnsExpectedError(t *testing.T) { defer server.Close() fetcher := NewFetcher() - metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client()) + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", server.URL, server.Client()) // Verify error require.Error(t, err) @@ -321,6 +321,66 @@ func TestFetchMetrics_HTTPStatusCodes_ReturnsExpectedError(t *testing.T) { } } +func TestFetchMetrics_AuthFailure_ErrorOmitsRawBody(t *testing.T) { + rawBody := "prom-diag-info" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(rawBody)) + })) + defer server.Close() + + fetcher := NewFetcher() + metrics, err := fetcher.FetchMetrics(context.Background(), "test-ds-uid", server.URL, server.Client()) + + require.Error(t, err) + require.Nil(t, metrics) + require.True(t, validator.IsValidationError(err)) + + validationErr := validator.GetValidationError(err) + require.NotContains(t, err.Error(), rawBody) + require.NotContains(t, validationErr.Message, rawBody) + _, hasBodyDetail := validationErr.Details["responseBody"] + require.False(t, hasBodyDetail, "unexpected detail key present") + _, hasURLDetail := validationErr.Details["url"] + require.False(t, hasURLDetail, "unexpected detail key present") + + serialized, jsonErr := json.Marshal(validationErr.Details) + require.NoError(t, jsonErr) + require.NotContains(t, string(serialized), rawBody) + require.NotContains(t, string(serialized), server.URL) + require.NotContains(t, validationErr.Message, server.URL) +} + +func TestFetchMetrics_ServerError_ErrorOmitsRawBody(t *testing.T) { + rawBody := "internal stack trace at /opt/prom-internal/handler.go:42" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(rawBody)) + })) + de ... [truncated]
← Back to Alerts View on GitHub →