This is an automated email from the ASF dual-hosted git repository.

git-hulk pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git


The following commit(s) were added to refs/heads/unstable by this push:
     new 6367221c5 fix(server): scope CLIENT LIST/INFO/KILL to caller's 
namespace (#3536)
6367221c5 is described below

commit 6367221c55ec6470df5531389107bbc911684d68
Author: hulk <[email protected]>
AuthorDate: Tue Jun 23 13:22:00 2026 +0800

    fix(server): scope CLIENT LIST/INFO/KILL to caller's namespace (#3536)
    
    Non-admin (tenant) connections could previously enumerate and terminate
    connections belonging to other namespaces — including the admin
    namespace and replication links — via CLIENT LIST and CLIENT KILL.
    Filter both per-worker iteration and the slave-thread enumeration by the
    caller's namespace, allowing only admin (default-namespace) callers to
    see or kill connections outside their own namespace.
    
    Assistant By Claude Opus 4.7
---
 src/commands/cmd_server.cc                         |   2 +-
 src/server/server.cc                               |  23 ++-
 src/server/server.h                                |   2 +-
 src/server/worker.cc                               |  12 +-
 src/server/worker.h                                |   2 +-
 .../unit/introspection/client_namespace_test.go    | 226 +++++++++++++++++++++
 6 files changed, 254 insertions(+), 13 deletions(-)

diff --git a/src/commands/cmd_server.cc b/src/commands/cmd_server.cc
index a624756db..94bb20386 100644
--- a/src/commands/cmd_server.cc
+++ b/src/commands/cmd_server.cc
@@ -633,7 +633,7 @@ class CommandClient : public Commander {
 
   Status Execute([[maybe_unused]] engine::Context &ctx, Server *srv, 
Connection *conn, std::string *output) override {
     if (subcommand_ == "list") {
-      *output = conn->VerbatimString("txt", srv->GetClientsStr());
+      *output = conn->VerbatimString("txt", srv->GetClientsStr(conn));
       return Status::OK();
     } else if (subcommand_ == "info") {
       *output = conn->VerbatimString("txt", conn->ToString());
diff --git a/src/server/server.cc b/src/server/server.cc
index fc763863b..4a63fc7b7 100644
--- a/src/server/server.cc
+++ b/src/server/server.cc
@@ -1805,16 +1805,19 @@ void Server::SlowlogPushEntryIfNeeded(const 
std::vector<std::string> *args, uint
   slow_log_.PushEntry(std::move(entry));
 }
 
-std::string Server::GetClientsStr() {
+std::string Server::GetClientsStr(const redis::Connection *conn) {
   std::string clients;
   for (const auto &t : worker_threads_) {
-    clients.append(t->GetWorker()->GetClientsStr());
+    clients.append(t->GetWorker()->GetClientsStr(conn));
   }
 
-  std::shared_lock<std::shared_mutex> guard(slave_threads_mu_);
-
-  for (const auto &st : slave_threads_) {
-    clients.append(st->GetConn()->ToString());
+  // Slave (replication) connections live outside any tenant namespace, so
+  // only admin (default-namespace) callers may enumerate them.
+  if (conn->IsAdmin()) {
+    std::shared_lock<std::shared_mutex> guard(slave_threads_mu_);
+    for (const auto &st : slave_threads_) {
+      clients.append(st->GetConn()->ToString());
+    }
   }
 
   return clients;
@@ -1824,13 +1827,19 @@ void Server::KillClient(int64_t *killed, const 
std::string &addr, uint64_t id, u
                         redis::Connection *conn) {
   *killed = 0;
 
-  // Normal clients and pubsub clients
+  // Normal clients and pubsub clients (per-worker filtering applies the
+  // namespace check for non-admin callers).
   for (const auto &t : worker_threads_) {
     int64_t killed_in_worker = 0;
     t->GetWorker()->KillClient(conn, id, addr, type, skipme, 
&killed_in_worker);
     *killed += killed_in_worker;
   }
 
+  // Replication links (master / slave) are not tenant-owned; only admin
+  // callers may terminate them, otherwise a non-admin tenant could
+  // disrupt replication.
+  if (!conn->IsAdmin()) return;
+
   // Slave clients
   {
     std::unique_lock<std::shared_mutex> guard(slave_threads_mu_);
diff --git a/src/server/server.h b/src/server/server.h
index 63b83e283..c0859dc91 100644
--- a/src/server/server.h
+++ b/src/server/server.h
@@ -310,7 +310,7 @@ class Server {
   int DecrMonitorClientNum();
   int IncrBlockedClientNum();
   int DecrBlockedClientNum();
-  std::string GetClientsStr();
+  std::string GetClientsStr(const redis::Connection *conn);
   uint64_t GetClientID();
   void KillClient(int64_t *killed, const std::string &addr, uint64_t id, 
uint64_t type, bool skipme,
                   redis::Connection *conn);
diff --git a/src/server/worker.cc b/src/server/worker.cc
index 45eceb705..150d27aab 100644
--- a/src/server/worker.cc
+++ b/src/server/worker.cc
@@ -536,13 +536,16 @@ void Worker::FeedMonitorConns(redis::Connection *conn, 
const std::string &respon
   }
 }
 
-std::string Worker::GetClientsStr() {
+std::string Worker::GetClientsStr(const redis::Connection *conn) {
   std::unique_lock<std::mutex> lock(conns_mu_);
 
   std::string output;
   for (const auto &iter : conns_) {
-    redis::Connection *conn = iter.second;
-    output.append(conn->ToString());
+    // Non-admin callers must only see clients in their own namespace. Admin
+    // (default-namespace) callers see every client. Mirrors the namespace
+    // filtering in Worker::FeedMonitorConns.
+    if (!conn->IsAdmin() && iter.second->GetNamespace() != 
conn->GetNamespace()) continue;
+    output.append(iter.second->ToString());
   }
 
   return output;
@@ -555,6 +558,9 @@ void Worker::KillClient(redis::Connection *self, uint64_t 
id, const std::string
   for (const auto &iter : conns_) {
     redis::Connection *conn = iter.second;
     if (skipme && self == conn) continue;
+    // Non-admin callers may only target clients in their own namespace, to
+    // prevent cross-tenant denial of service via CLIENT KILL.
+    if (!self->IsAdmin() && conn->GetNamespace() != self->GetNamespace()) 
continue;
 
     // no need to kill the client again if the kCloseAfterReply flag is set
     if (conn->IsFlagEnabled(redis::Connection::kCloseAfterReply)) {
diff --git a/src/server/worker.h b/src/server/worker.h
index 0e3ff7c03..cb37bf236 100644
--- a/src/server/worker.h
+++ b/src/server/worker.h
@@ -66,7 +66,7 @@ class Worker : EventCallbackBase<Worker>, 
EvconnlistenerBase<Worker> {
   void QuitMonitorConn(redis::Connection *conn);
   void FeedMonitorConns(redis::Connection *conn, const std::string &response);
 
-  std::string GetClientsStr();
+  std::string GetClientsStr(const redis::Connection *conn);
   void KillClient(redis::Connection *self, uint64_t id, const std::string 
&addr, uint64_t type, bool skipme,
                   int64_t *killed);
   void KickoutIdleClients(int timeout);
diff --git a/tests/gocase/unit/introspection/client_namespace_test.go 
b/tests/gocase/unit/introspection/client_namespace_test.go
new file mode 100644
index 000000000..98d4e9521
--- /dev/null
+++ b/tests/gocase/unit/introspection/client_namespace_test.go
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package introspection
+
+import (
+       "context"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/kvrocks/tests/gocase/util"
+       "github.com/redis/go-redis/v9"
+       "github.com/stretchr/testify/require"
+)
+
+// kvrocks' internal name for the default (admin) namespace.
+const defaultNS = "__namespace"
+
+// tenantConn is a TCP-level connection authenticated against a namespace
+// token (or requirepass, for admin). TCP rather than go-redis is used so
+// that a server-side kill is directly observable — go-redis transparently
+// reconnects, masking the close.
+type tenantConn struct {
+       *util.TCPClient
+}
+
+// dial opens a new authenticated TCP connection.
+func dial(t *testing.T, srv *util.KvrocksServer, password string) *tenantConn {
+       t.Helper()
+       c := srv.NewTCPClient()
+       t.Cleanup(func() { _ = c.Close() })
+       require.NoError(t, c.WriteArgs("AUTH", password))
+       c.MustRead(t, "+OK")
+       return &tenantConn{c}
+}
+
+// info returns one parsed field from CLIENT INFO (e.g. "id", "addr").
+func (c *tenantConn) info(t *testing.T, key string) string {
+       t.Helper()
+       require.NoError(t, c.WriteArgs("CLIENT", "INFO"))
+       // CLIENT INFO returns a bulk string. Connection::ToString already ends
+       // with \n, so the RESP frame is "$<len>\r\n<body>\n\r\n" and ReadLine
+       // breaks at the embedded \n. Consume header, body, then trailer to
+       // realign the buffer.
+       _, err := c.ReadLine()
+       require.NoError(t, err)
+       body, err := c.ReadLine()
+       require.NoError(t, err)
+       _, err = c.ReadLine()
+       require.NoError(t, err)
+       for field := range strings.FieldsSeq(body) {
+               if v, ok := strings.CutPrefix(field, key+"="); ok {
+                       return v
+               }
+       }
+       t.Fatalf("no %s= field in CLIENT INFO: %q", key, body)
+       return ""
+}
+
+// requireAlive asserts the connection still responds to PING.
+func (c *tenantConn) requireAlive(t *testing.T) {
+       t.Helper()
+       require.NoError(t, c.WriteArgs("PING"))
+       c.MustRead(t, "+PONG")
+}
+
+// requireKilled asserts the server has (or imminently will) close the 
connection.
+func (c *tenantConn) requireKilled(t *testing.T) {
+       t.Helper()
+       require.Eventually(t, func() bool {
+               if err := c.WriteArgs("PING"); err != nil {
+                       return true
+               }
+               _, err := c.ReadLine()
+               return err != nil
+       }, 5*time.Second, 100*time.Millisecond, "connection was expected to be 
killed")
+}
+
+// countNamespaceLines counts CLIENT LIST rows whose `namespace=` field equals 
ns.
+func countNamespaceLines(list, ns string) int {
+       count := 0
+       for line := range strings.SplitSeq(list, "\n") {
+               if strings.Contains(line, " namespace="+ns+" ") {
+                       count++
+               }
+       }
+       return count
+}
+
+// TestClientCommandNamespaceIsolation verifies that CLIENT LIST / INFO / KILL
+// are scoped to the caller's namespace for non-admin (tenant) connections,
+// while admin connections (authenticated via requirepass / default namespace)
+// retain server-wide visibility and control.
+//
+// These tests cover the cross-namespace isolation bypass on CLIENT LIST /
+// INFO / KILL: without filtering, a tenant authenticated against a
+// non-default namespace can both enumerate and terminate connections that
+// belong to other namespaces (including the admin namespace).
+func TestClientCommandNamespaceIsolation(t *testing.T) {
+       const adminPass = "adminpass"
+       srv := util.StartServer(t, map[string]string{"requirepass": adminPass})
+       defer srv.Close()
+
+       ctx := context.Background()
+
+       admin := srv.NewClientWithOption(&redis.Options{Password: adminPass})
+       defer func() { require.NoError(t, admin.Close()) }()
+       require.NoError(t, admin.Do(ctx, "NAMESPACE", "ADD", "ns1", 
"token1").Err())
+       require.NoError(t, admin.Do(ctx, "NAMESPACE", "ADD", "ns2", 
"token2").Err())
+
+       t.Run("CLIENT LIST: tenant only sees its own namespace", func(t 
*testing.T) {
+               _ = dial(t, srv, "token1")
+               _ = dial(t, srv, "token1")
+               _ = dial(t, srv, "token2")
+
+               ns1 := srv.NewClientWithOption(&redis.Options{Password: 
"token1"})
+               defer func() { require.NoError(t, ns1.Close()) }()
+
+               list := ns1.ClientList(ctx).Val()
+               require.NotEmpty(t, list)
+               require.GreaterOrEqual(t, countNamespaceLines(list, "ns1"), 2,
+                       "ns1 tenant should see at least its own connections, 
got:\n%s", list)
+               require.Equal(t, 0, countNamespaceLines(list, "ns2"),
+                       "ns1 tenant must not see ns2 connections, got:\n%s", 
list)
+               require.Equal(t, 0, countNamespaceLines(list, defaultNS),
+                       "ns1 tenant must not see default-namespace (admin) 
connections, got:\n%s", list)
+       })
+
+       t.Run("CLIENT LIST: admin sees every namespace", func(t *testing.T) {
+               _ = dial(t, srv, "token1")
+               _ = dial(t, srv, "token2")
+
+               list := admin.ClientList(ctx).Val()
+               require.GreaterOrEqual(t, countNamespaceLines(list, "ns1"), 1, 
list)
+               require.GreaterOrEqual(t, countNamespaceLines(list, "ns2"), 1, 
list)
+               require.GreaterOrEqual(t, countNamespaceLines(list, defaultNS), 
1, list)
+       })
+
+       t.Run("CLIENT INFO: only describes the caller's own connection", func(t 
*testing.T) {
+               ns1 := srv.NewClientWithOption(&redis.Options{Password: 
"token1"})
+               defer func() { require.NoError(t, ns1.Close()) }()
+
+               info, err := ns1.Do(ctx, "CLIENT", "INFO").Text()
+               require.NoError(t, err)
+               require.Contains(t, info, " namespace=ns1 ")
+               require.NotContains(t, info, " namespace="+defaultNS+" ")
+               require.NotContains(t, info, " namespace=ns2 ")
+       })
+
+       t.Run("CLIENT KILL by ID: tenant cannot kill another namespace", func(t 
*testing.T) {
+               conn2 := dial(t, srv, "token2")
+               attacker := srv.NewClientWithOption(&redis.Options{Password: 
"token1"})
+               defer func() { require.NoError(t, attacker.Close()) }()
+
+               killed := attacker.ClientKillByFilter(ctx, "id", conn2.info(t, 
"id")).Val()
+               require.EqualValues(t, 0, killed,
+                       "ns1 tenant must not be able to kill a ns2 connection 
by ID")
+               conn2.requireAlive(t)
+       })
+
+       t.Run("CLIENT KILL by ID: tenant cannot kill an admin connection", 
func(t *testing.T) {
+               adminConn := dial(t, srv, adminPass)
+               attacker := srv.NewClientWithOption(&redis.Options{Password: 
"token1"})
+               defer func() { require.NoError(t, attacker.Close()) }()
+
+               killed := attacker.ClientKillByFilter(ctx, "id", 
adminConn.info(t, "id")).Val()
+               require.EqualValues(t, 0, killed,
+                       "ns1 tenant must not be able to kill an 
admin/default-namespace connection")
+               adminConn.requireAlive(t)
+       })
+
+       t.Run("CLIENT KILL by ADDR: tenant cannot kill another namespace", 
func(t *testing.T) {
+               conn2 := dial(t, srv, "token2")
+               attacker := srv.NewClientWithOption(&redis.Options{Password: 
"token1"})
+               defer func() { require.NoError(t, attacker.Close()) }()
+
+               // The legacy "CLIENT KILL <addr>" form should reply with an 
error
+               // ("No such client") because, from ns1's perspective, the ns2
+               // connection does not exist.
+               err := attacker.ClientKill(ctx, conn2.info(t, "addr")).Err()
+               require.Error(t, err, "ns1 tenant must not be able to kill a 
ns2 connection by ADDR")
+               conn2.requireAlive(t)
+       })
+
+       t.Run("CLIENT KILL TYPE normal: tenant only affects its own namespace", 
func(t *testing.T) {
+               conn1 := dial(t, srv, "token1")
+               conn2 := dial(t, srv, "token2")
+               adminConn := dial(t, srv, adminPass)
+
+               attacker := srv.NewClientWithOption(&redis.Options{Password: 
"token1"})
+               defer func() { require.NoError(t, attacker.Close()) }()
+
+               killed := attacker.ClientKillByFilter(ctx, "skipme", "yes", 
"type", "normal").Val()
+               require.GreaterOrEqual(t, killed, int64(1))
+
+               conn1.requireKilled(t)
+               conn2.requireAlive(t)
+               adminConn.requireAlive(t)
+       })
+
+       t.Run("CLIENT KILL: admin retains full server-wide power", func(t 
*testing.T) {
+               conn2 := dial(t, srv, "token2")
+
+               killed := admin.ClientKillByFilter(ctx, "id", conn2.info(t, 
"id")).Val()
+               require.EqualValues(t, 1, killed,
+                       "admin must be able to kill a connection in any 
namespace by ID")
+               conn2.requireKilled(t)
+       })
+}

Reply via email to