Denial of Service (Availability) due to improper KV key validation for colon in resource names

HIGH
grafana/grafana
Commit: 3d0769bad618
Affected: 12.0.0 - 12.3.x (prior to 12.4.0)
2026-05-08 15:40 UTC

Description

The commit fixes an Availability/Denial of Service vulnerability in Grafana's Unified Storage where the KV key regex did not allow ':' in the Name segment. Grafana's resource-name validation permits ':' (e.g., user-storage's '<service>:<userUID>'), but the unified storage KV layer previously rejected such keys, causing iterator scans to fail and potentially crash the resource server during startup/init. The patch expands the validKeyRegex to include ':' and adjusts tests to ensure colon-containing keys are preserved end-to-end, eliminating the crash risk.

Proof of Concept

PoC: Steps:\n1) Deploy Grafana 12.x with Unified Storage enabled.\n2) Create a user-storage resource named grafana-splash-screen:uid in the default namespace. This yields a KV key containing ':' in the Name segment.\n3) Trigger a KV-iteration path (e.g., server restart or a list/resync operation). In versions before 12.4.0, the colon-containing key would be rejected by the KV layer, leading to an error or crash during initialization. After applying the patch, the colon-containing key is accepted and initialization succeeds.\n\nNotes: The test suite added asserts that ':' is accepted by kv.IsValidKey and preserved end-to-end, aligning the KV validation with Grafana's resource-name validators.

Commit Details

Author: Rafael Bortolon Paulovic

Date: 2026-05-08 15:30 UTC

Message:

Unified Storage: Allow ':' in KV key regex for user-storage names (#124471) * Unified Storage: Encode ':' in resource Names before they reach the KV layer The Grafana resource-name validator allows ':' (e.g. user-storage's "<service>:<userUID>" convention), but the unified storage KV layer's validKeyRegex does not. The kvtest cell crash-looped because a "grafana-splash-screen:<uid>" object produced a KV key the server rejected on every iterator scan, failing resource server init. Encode disallowed bytes in the Name segment as ~XX hex (and decode on parse) in DataKey.String / StringWithGUID / ParseDataKeyParts and in ListRequestKey.Prefix / GetRequestKey.Prefix, so on-disk keys stay within the KV-safe character set while DataKey.Name keeps its original form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Replace encode/decode with one-line regex change Drop the EncodeKeyName / DecodeKeyName helpers and the encoding calls in DataKey.String / StringWithGUID / ParseDataKeyParts and Prefix(). Instead, add ':' to validKeyRegex so the KV layer accepts keys for resource Names that already pass the Grafana name validator (which permits ':' for the user-storage <service>:<userUID> convention). The regex change is a strict superset, requires no migration, recovers the existing kvtest data without manual cleanup, and avoids any runtime encode/decode cost. Tests: positive cases for ':' in IsValidKey, the kvtest reproduction, and the badger+sqlkv integration test all assert the literal colon survives end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: clean up userstorage colon subtest, drop standalone reproduction - Drop TestDataKey_KvtestSplashScreenReproduction; it duplicated coverage the table-driven TestParseDataKeyParts_ResourceVersionMetadata already has. - Rewrite the "userstorage format with colon" subtest to match the surrounding subtest style: 5-part key (no section prefix), full field assertions in the same order as the namespaced subtest, expected/actual argument order corrected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: pin kv.validKeyRegex as a superset of grafana name validators Add TestKeyRegexCoversValidationCharSets to pkg/storage/unified/resource — a package that already imports both validation and kv, so neither package gains a dependency on the other. The test sweeps every ASCII byte in the middle of an alphanumeric-padded sample and asserts: for every byte upstream (IsValidGrafanaName / IsValidNamespace / IsValidGroup / IsValidResource) accepts in the sample, kv.IsValidKey must also accept the sample. Anchors in qualifiedNameFmt (start/end alphanumeric) and resourceFmt (start alpha) are sidestepped by fixing the probe byte at position 2. This catches the next regression of the kvtest crash class: silently widening grafanaNameFmt without widening kv.validKeyRegex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Availability / Denial of Service

Confidence: HIGH

Reasoning:

The commit expands the KV key validation to allow ':' in resource names, preventing server crashes during iteration that previously occurred when keys with ':' were encountered. This fixes an availability/denial-of-service risk where invalid keys caused the server to reject or crash, impacting service uptime. The change includes tests ensuring the new key format is accepted and preserved end-to-end.

Verification Assessment

Vulnerability Type: Denial of Service (Availability) due to improper KV key validation for colon in resource names

Confidence: HIGH

Affected Versions: 12.0.0 - 12.3.x (prior to 12.4.0)

Code Diff

diff --git a/pkg/storage/unified/resource/keys_test.go b/pkg/storage/unified/resource/keys_test.go index 60e6920c144aa..f4d4e622350a1 100644 --- a/pkg/storage/unified/resource/keys_test.go +++ b/pkg/storage/unified/resource/keys_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/apimachinery/validation" + "github.com/grafana/grafana/pkg/storage/unified/resource/kv" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) @@ -266,3 +268,37 @@ func TestVerifyRequestKeyCollection(t *testing.T) { }) } } + +// TestKeyRegexCoversValidationCharSets pins the contract that the unified +// storage KV regex (kv.IsValidKey) accepts every byte the upstream Grafana +// resource-name validators (IsValidGrafanaName / IsValidNamespace / +// IsValidGroup / IsValidResource) accept. +// +// Why: ensure KV regex supports Grafana API regex expressions +// +// The test lives in pkg/storage/unified/resource to avoid dependency between +// validation and kv packages. +func TestKeyRegexCoversValidationCharSets(t *testing.T) { + validators := map[string]func(string) []string{ + "IsValidGrafanaName": validation.IsValidGrafanaName, + "IsValidNamespace": validation.IsValidNamespace, + "IsValidGroup": validation.IsValidGroup, + "IsValidResource": validation.IsValidResource, + } + + // so upstream regex don't reject the probe byte for the wrong reason. + // Only sweep ASCII (0..127) because upstream regex's characters are ASCII. + for b := 0; b < 128; b++ { + // Probe ASCII bytes between a alphanumeric string to pass name validation + sample := "ab" + string(rune(b)) + "cd" + + for name, accepts := range validators { + if errs := accepts(sample); len(errs) > 0 { + continue + } + require.Truef(t, kv.IsValidKey(sample), + "%s accepts rune %q in sample %q, but kv.IsValidKey rejects it", + name, rune(b), sample) + } + } +} diff --git a/pkg/storage/unified/resource/kv/datastoretypes_test.go b/pkg/storage/unified/resource/kv/datastoretypes_test.go index 139ae0f6bcfe8..22a409b33a07f 100644 --- a/pkg/storage/unified/resource/kv/datastoretypes_test.go +++ b/pkg/storage/unified/resource/kv/datastoretypes_test.go @@ -66,6 +66,27 @@ func TestParseDataKeyParts_ResourceVersionMetadata(t *testing.T) { require.Equal(t, "f", dk.Folder) require.Equal(t, []string{"1", "created", "f", "guid-xyz"}, rvParts) }) + + t.Run("userstorage format with colon", func(t *testing.T) { + t.Parallel() + // User-storage names follow the "<service>:<userUID>" convention + // enforced by pkg/registry/apis/userstorage/strategy.go, so the Name + // segment of a KV key for these resources contains ':'. The KV regex + // must accept that, otherwise iterator-side validation rejects every + // freshly-written user-storage key with "received invalid key from + // server" and the resource server cannot init. + parts := strings.Split("userstorage.grafana.app/user-storage/default/grafana-splash-screen:myuser1234abcd/2052318477101322240~created~", "/") + dk, rvParts, err := ParseDataKeyParts(parts) + require.NoError(t, err) + require.Equal(t, "userstorage.grafana.app", dk.Group) + require.Equal(t, "user-storage", dk.Resource) + require.Equal(t, "default", dk.Namespace) + require.Equal(t, "grafana-splash-screen:myuser1234abcd", dk.Name) + require.Equal(t, int64(2052318477101322240), dk.ResourceVersion) + require.Equal(t, DataActionCreated, dk.Action) + require.Equal(t, "", dk.Folder) + require.Equal(t, []string{"2052318477101322240", "created", ""}, rvParts) + }) } func TestParseKeyWithGUID(t *testing.T) { diff --git a/pkg/storage/unified/resource/kv/kv.go b/pkg/storage/unified/resource/kv/kv.go index a820fb480b46b..7a3af9c36328c 100644 --- a/pkg/storage/unified/resource/kv/kv.go +++ b/pkg/storage/unified/resource/kv/kv.go @@ -433,9 +433,9 @@ func PrefixRangeEnd(prefix string) string { var ( // validKeyRegex validates keys used in the unified storage - // Keys can contain alphanumeric characters (both upper and lowercase), '-', '.', '/', and '~' + // Keys can contain alphanumeric characters (both upper and lowercase), ':', '-', '.', '/', and '~' // Any combination of these characters is allowed as long as the key is not empty - validKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9./~_-]+$`) + validKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9.:/~_-]+$`) ) func IsValidKey(key string) bool { diff --git a/pkg/storage/unified/resource/kv/kv_test.go b/pkg/storage/unified/resource/kv/kv_test.go index 1da1dbd3e855a..85afc8e6d4c83 100644 --- a/pkg/storage/unified/resource/kv/kv_test.go +++ b/pkg/storage/unified/resource/kv/kv_test.go @@ -242,6 +242,9 @@ func TestIsValidKey(t *testing.T) { {"underscores", "a_b", true}, {"key with underscores and mixed chars", "4_D6mSh4z", true}, {"complex key with underscores", "ns/group_name/resource-name/Name_123~action-type", true}, + {"colon in name segment", "a:b", true}, + {"user-storage key with colon", + "userstorage.grafana.app/user-storage/default/grafana-splash-screen:myuser1234abcd/2052318477101322240~created~", true}, // invalid keys {"empty key", "", false},
← Back to Alerts View on GitHub →