From e36bd99ef642f05abff2afdd5b0cdcbacf4e1e18 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 17 Mar 2026 12:47:00 +0100 Subject: [PATCH] security: enforce channel membership check in handleTopic (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `handleTopic` in `internal/handlers/api.go` did NOT check that the 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. ## Changes - **`internal/handlers/api.go`**: Added `IsChannelMember` check after resolving the channel ID and before calling `executeTopic`, mirroring the existing pattern in `handleChannelMsg`. Non-members now receive `ERR_NOTONCHANNEL` (442). - **`internal/handlers/api_test.go`**: Added `TestTopicNonMember` — creates a channel with one user, then verifies a second user who hasn't joined receives numeric 442 when attempting to set the topic. ## Testing - All existing tests pass - New `TestTopicNonMember` test validates the fix - `docker build .` passes clean (formatting, linting, tests, build) closes #33 Co-authored-by: user Reviewed-on: https://git.eeqj.de/sneak/chat/pulls/75 Co-authored-by: clawbot Co-committed-by: clawbot --- 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 6be632f..7a06e49 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1641,6 +1641,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 08755d0..68a1d2e 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -1140,6 +1140,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")