From 53e0abb134a14910007e302710df6619bdc6fc29 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Mar 2026 02:14:32 -0700 Subject: [PATCH] security: enforce channel membership check in handleTopic handleTopic did not verify that the requesting user was a member of the channel before allowing them to set a topic. Any authenticated user could set the topic on any channel they hadn't joined. Add an IsChannelMember check after resolving the channel and before calling executeTopic, mirroring the existing pattern in handleChannelMsg. Non-members now receive ERR_NOTONCHANNEL (442). Add TestTopicNonMember to verify the fix. --- internal/handlers/api.go | 26 +++++++++++++++++++++++++ internal/handlers/api_test.go | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 74f9f9a..1d959b0 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1636,6 +1636,32 @@ func (hdlr *Handlers) handleTopic( return } + isMember, err := hdlr.params.Database.IsChannelMember( + request.Context(), chID, sessionID, + ) + if err != nil { + hdlr.log.Error( + "check membership failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + if !isMember { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNotOnChannel, nick, []string{channel}, + "You're not on that channel", + ) + + return + } + hdlr.executeTopic( writer, request, sessionID, clientID, nick, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index e4da9cb..a482757 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -1134,6 +1134,42 @@ func TestTopicMissingBody(t *testing.T) { } } +func TestTopicNonMember(t *testing.T) { + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_topic") + bobToken := tserver.createSession("bob_topic") + + // Only alice joins the channel. + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: "#topicpriv", + }) + + // Drain bob's initial messages. + _, lastID := tserver.pollMessages(bobToken, 0) + + // Bob tries to set topic without joining. + status, _ := tserver.sendCommand( + bobToken, + map[string]any{ + commandKey: "TOPIC", + toKey: "#topicpriv", + bodyKey: []string{"Hijacked topic"}, + }, + ) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(bobToken, lastID) + + if !findNumeric(msgs, "442") { + t.Fatalf( + "expected ERR_NOTONCHANNEL (442), got %v", + msgs, + ) + } +} + func TestPing(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("ping_user")