CVE Patch Review

GHSA-XG4H-6GFC-H4M8: Root-cause Fix for etcd Watch RBAC Bypass

GHSA-XG4H-6GFC-H4M8 · Updated 2026-07-25 Root-cause

Summary

The patch addresses an authorization bypass in etcd's Watch API where an open-ended range sentinel was normalized before RBAC evaluation, causing a ">= key" watch to be authorized as an exact-key watch. Based on the supplied test changes and advisory context, the fix moves permission checking ahead of RangeEnd normalization, which directly corrects the semantic mismatch that enabled the bypass.

Analysis

Vulnerability

GHSA-XG4H-6GFC-H4M8 describes an authorization bypass in etcd's gRPC Watch handler. The issue is a semantic mismatch between wire-format range encoding and the authorization layer's interpretation of that range. Per the supplied test commentary in the referenced commits, a client using clientv3.WithFromKey() sends an open-ended watch as RangeEnd == 0x00. In server/etcdserver/api/v3rpc/watch.go, that sentinel was rewritten to []byte{} before the permission check. The authorization logic in server/auth/range_perm_cache.go then evaluated len(rangeEnd) == 0, treating the request like an exact-key query rather than an open-ended [key, +inf) range.

The result was that a principal with READ permission on a single key could create a watch that observed all lexicographically greater keys. This is a classic time-of-check/representation bug: the object was normalized for downstream watch semantics before RBAC consumed it, and the normalized representation collapsed two distinct meanings relevant to authorization.

if len(creq.RangeEnd) == 0 {
    // force nil since watchstream.Watch distinguishes
    // between nil and []byte{} for single key / >=
    creq.RangeEnd = nil
}
if len(creq.RangeEnd) == 1 && creq.RangeEnd[0] == 0 {
    // support  >= key queries
    creq.RangeEnd = []byte{}
}

The vulnerable behavior and root cause are documented in the e2e test updates shipped with the fixes in commit 6643f80602461a6095c9b294b6512fd9719bef41, commit afeaa624da19085b47fb5ccc7c22a8c421bc2eae, and commit e863b001bbf3367003a543aa3099db9892134cd7. An additional summary is available at cvereports.com.

Patch

The supplied code digest does not show a visible line-level change in the normalization snippet itself, but the updated test text explicitly states the intended fix: permission checking now runs against the unmodified RangeEnd, before the []byte{}/nil rewrite needed by watchStream.Watch. That is the correct repair strategy because it preserves the original request semantics for RBAC while still allowing internal watch machinery to receive its preferred normalized representation.

The strongest evidence for the patch behavior is the renamed and rewritten e2e test. The old test demonstrated event leakage to a low-permission user. The new test expects the watch to be canceled and the channel closed when that same user attempts a WithFromKey() watch beyond its exact-key grant.

// Fixed by running the permission check against the
// unmodified RangeEnd, before the []byte{}/nil rewrite for watchStream.Watch.
...
require.True(t, wresp.Canceled)
...
if ok {
    t.Fatalf("expected watch channel to be closed after denial, got response: %+v", wresp)
}

This is a source-grounded root-cause fix because it restores the correct ordering: authorize first on the original wire semantics, then normalize for execution. It does not merely filter leaked events after watch creation, nor does it special-case one client helper without addressing the underlying representation collapse.

Review

Pros

  • Fixes the actual semantic bug: authorization now evaluates the original open-ended range request instead of a normalized representation that aliases to exact-key semantics.
  • Preserves existing watch subsystem behavior by keeping the nil vs []byte{} normalization for watchStream.Watch, but moves it after the security boundary.
  • Adds an end-to-end regression test that validates the security property from the caller perspective: unauthorized watch creation is denied, canceled, and closed.
  • The test narrative clearly documents the exploit path and the corrected behavior, which improves maintainability and reduces the chance of reintroducing the bug in future refactors.
  • The same fix pattern appears to have been backported across multiple branches, as indicated by the three referenced commits.

Cons

  • The provided patch digest omits the exact changed lines in watch.go, so reviewers must infer the implementation from the updated test and advisory context rather than directly inspect the reordered authorization call in the snippet.
  • The bug class suggests broader risk anywhere request normalization occurs before RBAC or admission checks. The supplied materials only demonstrate coverage for the WithFromKey() watch case.
  • The authorization layer's reliance on len(rangeEnd) == 0 to distinguish semantics remains subtle. Even with the ordering fixed, this representation-sensitive contract is easy to misuse elsewhere.

Verdict

Root-cause.

Based on the advisory and the updated e2e tests in 6643f80602461a6095c9b294b6512fd9719bef41, afeaa624da19085b47fb5ccc7c22a8c421bc2eae, and e863b001bbf3367003a543aa3099db9892134cd7, the patch corrects the ordering flaw that caused RBAC to authorize the wrong operation. The vulnerable condition was not merely insufficient validation; it was premature normalization across a trust boundary. Moving the permission check ahead of normalization directly addresses that root cause. Follow-up hardening would still be prudent: audit other APIs for pre-auth normalization and consider making range semantics explicit in types rather than inferred from empty-slice length.

Sources