Skip to content

Commit 6367221

Browse files
authored
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
1 parent b0ae44f commit 6367221

6 files changed

Lines changed: 254 additions & 13 deletions

File tree

src/commands/cmd_server.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ class CommandClient : public Commander {
633633

634634
Status Execute([[maybe_unused]] engine::Context &ctx, Server *srv, Connection *conn, std::string *output) override {
635635
if (subcommand_ == "list") {
636-
*output = conn->VerbatimString("txt", srv->GetClientsStr());
636+
*output = conn->VerbatimString("txt", srv->GetClientsStr(conn));
637637
return Status::OK();
638638
} else if (subcommand_ == "info") {
639639
*output = conn->VerbatimString("txt", conn->ToString());

src/server/server.cc

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,16 +1805,19 @@ void Server::SlowlogPushEntryIfNeeded(const std::vector<std::string> *args, uint
18051805
slow_log_.PushEntry(std::move(entry));
18061806
}
18071807

1808-
std::string Server::GetClientsStr() {
1808+
std::string Server::GetClientsStr(const redis::Connection *conn) {
18091809
std::string clients;
18101810
for (const auto &t : worker_threads_) {
1811-
clients.append(t->GetWorker()->GetClientsStr());
1811+
clients.append(t->GetWorker()->GetClientsStr(conn));
18121812
}
18131813

1814-
std::shared_lock<std::shared_mutex> guard(slave_threads_mu_);
1815-
1816-
for (const auto &st : slave_threads_) {
1817-
clients.append(st->GetConn()->ToString());
1814+
// Slave (replication) connections live outside any tenant namespace, so
1815+
// only admin (default-namespace) callers may enumerate them.
1816+
if (conn->IsAdmin()) {
1817+
std::shared_lock<std::shared_mutex> guard(slave_threads_mu_);
1818+
for (const auto &st : slave_threads_) {
1819+
clients.append(st->GetConn()->ToString());
1820+
}
18181821
}
18191822

18201823
return clients;
@@ -1824,13 +1827,19 @@ void Server::KillClient(int64_t *killed, const std::string &addr, uint64_t id, u
18241827
redis::Connection *conn) {
18251828
*killed = 0;
18261829

1827-
// Normal clients and pubsub clients
1830+
// Normal clients and pubsub clients (per-worker filtering applies the
1831+
// namespace check for non-admin callers).
18281832
for (const auto &t : worker_threads_) {
18291833
int64_t killed_in_worker = 0;
18301834
t->GetWorker()->KillClient(conn, id, addr, type, skipme, &killed_in_worker);
18311835
*killed += killed_in_worker;
18321836
}
18331837

1838+
// Replication links (master / slave) are not tenant-owned; only admin
1839+
// callers may terminate them, otherwise a non-admin tenant could
1840+
// disrupt replication.
1841+
if (!conn->IsAdmin()) return;
1842+
18341843
// Slave clients
18351844
{
18361845
std::unique_lock<std::shared_mutex> guard(slave_threads_mu_);

src/server/server.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ class Server {
310310
int DecrMonitorClientNum();
311311
int IncrBlockedClientNum();
312312
int DecrBlockedClientNum();
313-
std::string GetClientsStr();
313+
std::string GetClientsStr(const redis::Connection *conn);
314314
uint64_t GetClientID();
315315
void KillClient(int64_t *killed, const std::string &addr, uint64_t id, uint64_t type, bool skipme,
316316
redis::Connection *conn);

src/server/worker.cc

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -536,13 +536,16 @@ void Worker::FeedMonitorConns(redis::Connection *conn, const std::string &respon
536536
}
537537
}
538538

539-
std::string Worker::GetClientsStr() {
539+
std::string Worker::GetClientsStr(const redis::Connection *conn) {
540540
std::unique_lock<std::mutex> lock(conns_mu_);
541541

542542
std::string output;
543543
for (const auto &iter : conns_) {
544-
redis::Connection *conn = iter.second;
545-
output.append(conn->ToString());
544+
// Non-admin callers must only see clients in their own namespace. Admin
545+
// (default-namespace) callers see every client. Mirrors the namespace
546+
// filtering in Worker::FeedMonitorConns.
547+
if (!conn->IsAdmin() && iter.second->GetNamespace() != conn->GetNamespace()) continue;
548+
output.append(iter.second->ToString());
546549
}
547550

548551
return output;
@@ -555,6 +558,9 @@ void Worker::KillClient(redis::Connection *self, uint64_t id, const std::string
555558
for (const auto &iter : conns_) {
556559
redis::Connection *conn = iter.second;
557560
if (skipme && self == conn) continue;
561+
// Non-admin callers may only target clients in their own namespace, to
562+
// prevent cross-tenant denial of service via CLIENT KILL.
563+
if (!self->IsAdmin() && conn->GetNamespace() != self->GetNamespace()) continue;
558564

559565
// no need to kill the client again if the kCloseAfterReply flag is set
560566
if (conn->IsFlagEnabled(redis::Connection::kCloseAfterReply)) {

src/server/worker.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class Worker : EventCallbackBase<Worker>, EvconnlistenerBase<Worker> {
6666
void QuitMonitorConn(redis::Connection *conn);
6767
void FeedMonitorConns(redis::Connection *conn, const std::string &response);
6868

69-
std::string GetClientsStr();
69+
std::string GetClientsStr(const redis::Connection *conn);
7070
void KillClient(redis::Connection *self, uint64_t id, const std::string &addr, uint64_t type, bool skipme,
7171
int64_t *killed);
7272
void KickoutIdleClients(int timeout);
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package introspection
21+
22+
import (
23+
"context"
24+
"strings"
25+
"testing"
26+
"time"
27+
28+
"github.com/apache/kvrocks/tests/gocase/util"
29+
"github.com/redis/go-redis/v9"
30+
"github.com/stretchr/testify/require"
31+
)
32+
33+
// kvrocks' internal name for the default (admin) namespace.
34+
const defaultNS = "__namespace"
35+
36+
// tenantConn is a TCP-level connection authenticated against a namespace
37+
// token (or requirepass, for admin). TCP rather than go-redis is used so
38+
// that a server-side kill is directly observable — go-redis transparently
39+
// reconnects, masking the close.
40+
type tenantConn struct {
41+
*util.TCPClient
42+
}
43+
44+
// dial opens a new authenticated TCP connection.
45+
func dial(t *testing.T, srv *util.KvrocksServer, password string) *tenantConn {
46+
t.Helper()
47+
c := srv.NewTCPClient()
48+
t.Cleanup(func() { _ = c.Close() })
49+
require.NoError(t, c.WriteArgs("AUTH", password))
50+
c.MustRead(t, "+OK")
51+
return &tenantConn{c}
52+
}
53+
54+
// info returns one parsed field from CLIENT INFO (e.g. "id", "addr").
55+
func (c *tenantConn) info(t *testing.T, key string) string {
56+
t.Helper()
57+
require.NoError(t, c.WriteArgs("CLIENT", "INFO"))
58+
// CLIENT INFO returns a bulk string. Connection::ToString already ends
59+
// with \n, so the RESP frame is "$<len>\r\n<body>\n\r\n" and ReadLine
60+
// breaks at the embedded \n. Consume header, body, then trailer to
61+
// realign the buffer.
62+
_, err := c.ReadLine()
63+
require.NoError(t, err)
64+
body, err := c.ReadLine()
65+
require.NoError(t, err)
66+
_, err = c.ReadLine()
67+
require.NoError(t, err)
68+
for field := range strings.FieldsSeq(body) {
69+
if v, ok := strings.CutPrefix(field, key+"="); ok {
70+
return v
71+
}
72+
}
73+
t.Fatalf("no %s= field in CLIENT INFO: %q", key, body)
74+
return ""
75+
}
76+
77+
// requireAlive asserts the connection still responds to PING.
78+
func (c *tenantConn) requireAlive(t *testing.T) {
79+
t.Helper()
80+
require.NoError(t, c.WriteArgs("PING"))
81+
c.MustRead(t, "+PONG")
82+
}
83+
84+
// requireKilled asserts the server has (or imminently will) close the connection.
85+
func (c *tenantConn) requireKilled(t *testing.T) {
86+
t.Helper()
87+
require.Eventually(t, func() bool {
88+
if err := c.WriteArgs("PING"); err != nil {
89+
return true
90+
}
91+
_, err := c.ReadLine()
92+
return err != nil
93+
}, 5*time.Second, 100*time.Millisecond, "connection was expected to be killed")
94+
}
95+
96+
// countNamespaceLines counts CLIENT LIST rows whose `namespace=` field equals ns.
97+
func countNamespaceLines(list, ns string) int {
98+
count := 0
99+
for line := range strings.SplitSeq(list, "\n") {
100+
if strings.Contains(line, " namespace="+ns+" ") {
101+
count++
102+
}
103+
}
104+
return count
105+
}
106+
107+
// TestClientCommandNamespaceIsolation verifies that CLIENT LIST / INFO / KILL
108+
// are scoped to the caller's namespace for non-admin (tenant) connections,
109+
// while admin connections (authenticated via requirepass / default namespace)
110+
// retain server-wide visibility and control.
111+
//
112+
// These tests cover the cross-namespace isolation bypass on CLIENT LIST /
113+
// INFO / KILL: without filtering, a tenant authenticated against a
114+
// non-default namespace can both enumerate and terminate connections that
115+
// belong to other namespaces (including the admin namespace).
116+
func TestClientCommandNamespaceIsolation(t *testing.T) {
117+
const adminPass = "adminpass"
118+
srv := util.StartServer(t, map[string]string{"requirepass": adminPass})
119+
defer srv.Close()
120+
121+
ctx := context.Background()
122+
123+
admin := srv.NewClientWithOption(&redis.Options{Password: adminPass})
124+
defer func() { require.NoError(t, admin.Close()) }()
125+
require.NoError(t, admin.Do(ctx, "NAMESPACE", "ADD", "ns1", "token1").Err())
126+
require.NoError(t, admin.Do(ctx, "NAMESPACE", "ADD", "ns2", "token2").Err())
127+
128+
t.Run("CLIENT LIST: tenant only sees its own namespace", func(t *testing.T) {
129+
_ = dial(t, srv, "token1")
130+
_ = dial(t, srv, "token1")
131+
_ = dial(t, srv, "token2")
132+
133+
ns1 := srv.NewClientWithOption(&redis.Options{Password: "token1"})
134+
defer func() { require.NoError(t, ns1.Close()) }()
135+
136+
list := ns1.ClientList(ctx).Val()
137+
require.NotEmpty(t, list)
138+
require.GreaterOrEqual(t, countNamespaceLines(list, "ns1"), 2,
139+
"ns1 tenant should see at least its own connections, got:\n%s", list)
140+
require.Equal(t, 0, countNamespaceLines(list, "ns2"),
141+
"ns1 tenant must not see ns2 connections, got:\n%s", list)
142+
require.Equal(t, 0, countNamespaceLines(list, defaultNS),
143+
"ns1 tenant must not see default-namespace (admin) connections, got:\n%s", list)
144+
})
145+
146+
t.Run("CLIENT LIST: admin sees every namespace", func(t *testing.T) {
147+
_ = dial(t, srv, "token1")
148+
_ = dial(t, srv, "token2")
149+
150+
list := admin.ClientList(ctx).Val()
151+
require.GreaterOrEqual(t, countNamespaceLines(list, "ns1"), 1, list)
152+
require.GreaterOrEqual(t, countNamespaceLines(list, "ns2"), 1, list)
153+
require.GreaterOrEqual(t, countNamespaceLines(list, defaultNS), 1, list)
154+
})
155+
156+
t.Run("CLIENT INFO: only describes the caller's own connection", func(t *testing.T) {
157+
ns1 := srv.NewClientWithOption(&redis.Options{Password: "token1"})
158+
defer func() { require.NoError(t, ns1.Close()) }()
159+
160+
info, err := ns1.Do(ctx, "CLIENT", "INFO").Text()
161+
require.NoError(t, err)
162+
require.Contains(t, info, " namespace=ns1 ")
163+
require.NotContains(t, info, " namespace="+defaultNS+" ")
164+
require.NotContains(t, info, " namespace=ns2 ")
165+
})
166+
167+
t.Run("CLIENT KILL by ID: tenant cannot kill another namespace", func(t *testing.T) {
168+
conn2 := dial(t, srv, "token2")
169+
attacker := srv.NewClientWithOption(&redis.Options{Password: "token1"})
170+
defer func() { require.NoError(t, attacker.Close()) }()
171+
172+
killed := attacker.ClientKillByFilter(ctx, "id", conn2.info(t, "id")).Val()
173+
require.EqualValues(t, 0, killed,
174+
"ns1 tenant must not be able to kill a ns2 connection by ID")
175+
conn2.requireAlive(t)
176+
})
177+
178+
t.Run("CLIENT KILL by ID: tenant cannot kill an admin connection", func(t *testing.T) {
179+
adminConn := dial(t, srv, adminPass)
180+
attacker := srv.NewClientWithOption(&redis.Options{Password: "token1"})
181+
defer func() { require.NoError(t, attacker.Close()) }()
182+
183+
killed := attacker.ClientKillByFilter(ctx, "id", adminConn.info(t, "id")).Val()
184+
require.EqualValues(t, 0, killed,
185+
"ns1 tenant must not be able to kill an admin/default-namespace connection")
186+
adminConn.requireAlive(t)
187+
})
188+
189+
t.Run("CLIENT KILL by ADDR: tenant cannot kill another namespace", func(t *testing.T) {
190+
conn2 := dial(t, srv, "token2")
191+
attacker := srv.NewClientWithOption(&redis.Options{Password: "token1"})
192+
defer func() { require.NoError(t, attacker.Close()) }()
193+
194+
// The legacy "CLIENT KILL <addr>" form should reply with an error
195+
// ("No such client") because, from ns1's perspective, the ns2
196+
// connection does not exist.
197+
err := attacker.ClientKill(ctx, conn2.info(t, "addr")).Err()
198+
require.Error(t, err, "ns1 tenant must not be able to kill a ns2 connection by ADDR")
199+
conn2.requireAlive(t)
200+
})
201+
202+
t.Run("CLIENT KILL TYPE normal: tenant only affects its own namespace", func(t *testing.T) {
203+
conn1 := dial(t, srv, "token1")
204+
conn2 := dial(t, srv, "token2")
205+
adminConn := dial(t, srv, adminPass)
206+
207+
attacker := srv.NewClientWithOption(&redis.Options{Password: "token1"})
208+
defer func() { require.NoError(t, attacker.Close()) }()
209+
210+
killed := attacker.ClientKillByFilter(ctx, "skipme", "yes", "type", "normal").Val()
211+
require.GreaterOrEqual(t, killed, int64(1))
212+
213+
conn1.requireKilled(t)
214+
conn2.requireAlive(t)
215+
adminConn.requireAlive(t)
216+
})
217+
218+
t.Run("CLIENT KILL: admin retains full server-wide power", func(t *testing.T) {
219+
conn2 := dial(t, srv, "token2")
220+
221+
killed := admin.ClientKillByFilter(ctx, "id", conn2.info(t, "id")).Val()
222+
require.EqualValues(t, 1, killed,
223+
"admin must be able to kill a connection in any namespace by ID")
224+
conn2.requireKilled(t)
225+
})
226+
}

0 commit comments

Comments
 (0)