grpc proxy serializable Range cache leaks protected keys across auth identities#22112
grpc proxy serializable Range cache leaks protected keys across auth identities#22112goingforstudying-ctrl wants to merge 5 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: goingforstudying-ctrl The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @goingforstudying-ctrl. Thanks for your PR. I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with Regular contributors should join the org to skip this step. Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
…tity The grpc-proxy's serializable Range cache keyed entries only on the marshalled RangeRequest, never on who was asking. Once a privileged client read a key through the proxy, any other client issuing the same serializable Range got the cached response back without the backend ever seeing the request or enforcing its auth policy. Low-priv or unauthenticated callers could read arbitrary protected keys this way, and revoking a permission did nothing while a stale entry sat in the proxy's LRU. Two changes close the hole: - The cache key now includes a SHA-256 of the caller's auth token, so two different credentials never share an entry. Tokenless requests share one entry, same as before. - Auth-mutating RPCs (RoleGrantPermission, UserRevokeRole, etc.) forwarded through the auth proxy flush the whole KV cache, so a caller whose permissions were just revoked can't keep reading previously cached data. Regression tests cover cross-identity leaks for serializable and linearizable reads, plus the permission-revocation path driven through the real AuthProxy. Fixes etcd-io#22065 Signed-off-by: goingforstudying-ctrl <goingforstudying@gmail.com>
Add an explicit bounds check before computing the capacity hint in keyFunc to silence CodeQL size-computation-for-allocation overflow warning. The sum len(b)+1+len(authKey) cannot overflow on supported platforms (proto.Marshal output is bounded by MaxRequestBytes), but the explicit guard documents the invariant and silences the analyzer. Signed-off-by: goingforstudying-ctrl <goingforstudying@gmail.com>
043167b to
297ac7e
Compare
…sion CodeQL flags the early 'return string(b)' path as a potential allocation-size overflow because it cannot prove the size of the proto.Marshal output is bounded. Routing every call through the pre-sized buffer path removes the flagged conversion and makes the overflow guard cover the entire allocation, not just the authKey branch. Behavior is unchanged: keys for distinct (req, authKey) pairs remain distinct, and the empty-authKey case still produces a stable, deterministic key. Signed-off-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
Deln0r
left a comment
There was a problem hiding this comment.
Thanks for tackling this — the auth-bypass is real and the identity-scoped keying is the right shape. A few notes.
Correctness looks solid to me: denials are never cached (Add only runs after a successful backend Do), so the grant direction is safe by construction; the auth-disabled path is unchanged since an empty token shares one bucket as before; and the regression tests genuinely fail without the fix (low-priv/anon would be served root's primed entry, so the require.Error assertions would trip). Nice, thorough coverage.
Two things worth changing and one to document:
-
NewAuthProxy takes the whole *KvProxy just to read kvp.cache, which forces exporting the KvProxy type alias. You already added a Cache() cache.Cache method but nothing uses it — passing kvp.Cache() and taking a cache.Cache here would drop the exported concrete type, loosen the coupling, and put that method to use.
-
In TestCacheAuthKeyIsolation, the final assertion's comment ("alice and '' share the same key when authKey is empty") is inaccurate: keyFunc appends authKey, so keyFunc(req,"alice") and keyFunc(req,"") are distinct keys. That Get(req,"alice") hits because the earlier Add(req,resp,"alice") entry is still resident, not because "" collides with a named identity. Worth fixing the comment (or the assertion) so it doesn't imply "" acts as a wildcard.
-
The cache flush only fires for auth mutations that transit the proxy's AuthProxy. Permission changes applied directly against the backend won't flush the proxy cache, leaving a same-token stale-read window until eviction. Might be worth a short note on Clear()/flushCacheOnMutation so operators know the proxy only observes auth changes that pass through it.
|
Thanks for the review. I updated NewAuthProxy to take a cache.Cache interface directly instead of the concrete *KvProxy, which drops the exported type coupling and uses the existing Cache() method. I also fixed the inaccurate comment in TestCacheAuthKeyIsolation and documented the proxy-only flush limitation on Clear(). Pushed as 0424bf6. |
Pass the cache interface directly to NewAuthProxy instead of the whole *KvProxy. This drops the exported concrete-type coupling and puts the existing Cache() method to use. Also fix the stale comment in TestCacheAuthKeyIsolation: the final Get(req,"alice") hits because the earlier Add(req,resp,"alice") entry is still resident, not because "" collides with a named identity. And document that the proxy only flushes on auth mutations that pass through it, not direct backend changes. Signed-off-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
0424bf6 to
2f3ab5f
Compare
@goingforstudying-ctrl |
|
Thanks for the detailed review. On the three points:
|
The proxy cache is only flushed for auth mutations that transit the AuthProxy. Permission changes applied directly against the backend leave a same-token stale-read window until eviction. Add a note on flushCacheOnMutation so the limitation is documented where the flush is triggered. Signed-off-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
00e13c3 to
cb665e8
Compare
Was poking at the grpc-proxy caching code while debugging an authz mystery in a staging setup (clients behind the proxy occasionally saw keys their roles shouldn't allow) and it turned out the proxy cache basically doesn't care who's asking. This is the same thing reported in #22065.
The problem:
kvProxy.Rangeserves serializable reads out of an LRU keyed only on the marshalledRangeRequest. The caller's auth token never enters the key. So once any client (say root) does a serializableRangeon/secret, the proxy stuffs the response into the cache, and the next client asking for the exact same serializable range gets it replayed straight from the LRU. The backend never sees the second request, so its auth check never runs. A user with zero permissions — or an unauthenticated client — can read anything a previous caller happened to cache. Linearizable reads are also cached "as serializable", so the leak isn't limited to clients that explicitly ask for stale reads. And since permission changes never touch the cache, revoking access doesn't help while the entry is still warm either.I went with option (a) from the issue: scope cache entries by the caller identity, plus flush on auth mutations. Concretely:
NewAuthProxytakes the*KvProxyso it can reach the shared cache; the one call site in etcdmain and the test-framework call site are updated.One trade-off worth mentioning: two tokens belonging to the same user (e.g. re-authenticated) get separate entries, so per-user hit rates drop a bit. Seemed fine — correctness beats a slightly colder cache, and entries for the same user converge on the next read anyway.
Regression coverage:
server/proxy/grpcproxy/cache: unit tests for identity-scoped keying,Clear(), key determinism.tests/integration/proxy/grpcproxy/kv_auth_cache_test.go: spins up a real auth-enabled member behind a proxy wired the same way production is (token-forwarding interceptors), thengo test ./server/proxy/grpcproxy/... ./tests/integration/proxy/grpcproxy/...and the auth-flavored integration suites intests/integrationandtests/common -tags integrationall pass locally.Fixes #22065