Enforce channel keys, invites, +s and +H on the IRC listener (closes #122)
check / check (push) Waiting to run

The IRC listener's JOIN now passes the channel key on, INVITE records
the invite, LIST and WHOIS hide +s channels from non-members with the
queries the HTTP API uses, and PRIVMSG to a +H channel gets 404.

The INVITE checks and the channel hashcash check move from the HTTP
handlers into the service, so both transports run the same code. An IRC
client cannot attach a stamp, so its PRIVMSG to a +H channel always
fails that check. The unfiltered channel list query, now unused, is
removed.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #139.
This commit is contained in:
2026-10-20 16:02:14 +02:00
committed by clawbot
parent 1308769a25
commit 3c725a9ca8
7 changed files with 494 additions and 450 deletions
+28 -39
View File
@@ -150,7 +150,9 @@ func (c *Conn) handlePrivmsg(
}
// handleJoin joins one or more channels via the shared
// service layer.
// service layer. The optional second parameter holds the
// channel keys, comma-separated in the order of the
// channels.
func (c *Conn) handleJoin(
ctx context.Context,
msg *Message,
@@ -164,14 +166,24 @@ func (c *Conn) handleJoin(
return
}
for chanName := range strings.SplitSeq(msg.Params[0], ",") {
var keys []string
if len(msg.Params) > 1 {
keys = strings.Split(msg.Params[1], ",")
}
for idx, chanName := range strings.Split(msg.Params[0], ",") {
chanName = strings.TrimSpace(chanName)
if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName
}
c.joinChannel(ctx, chanName)
key := ""
if idx < len(keys) {
key = keys[idx]
}
c.joinChannel(ctx, chanName, key)
}
}
@@ -179,10 +191,10 @@ func (c *Conn) handleJoin(
// and delivers topic/names on the wire.
func (c *Conn) joinChannel(
ctx context.Context,
channel string,
channel, key string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.nick, channel, "",
ctx, c.sessionID, c.nick, channel, key,
)
if err != nil {
c.sendIRCError(err)
@@ -763,10 +775,11 @@ func (c *Conn) handleNames(
c.deliverNames(ctx, channel, chID)
}
// handleList sends the channel list.
// handleList sends the channel list, leaving out +s
// channels the user is not on.
func (c *Conn) handleList(ctx context.Context) {
channels, err := c.database.ListAllChannelsWithCounts(
ctx,
channels, err := c.database.ListAllChannelsWithCountsFiltered(
ctx, c.sessionID,
)
if err != nil {
c.sendNumeric(
@@ -886,14 +899,15 @@ func (c *Conn) whoisOper(
}
}
// whoisChannels sends 319 RPL_WHOISCHANNELS.
// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out
// +s channels the querier is not on.
func (c *Conn) whoisChannels(
ctx context.Context,
target string,
targetID int64,
) {
userChannels, _ := c.database.GetSessionChannels(
ctx, targetID,
userChannels, _ := c.database.GetSessionChannelsFiltered(
ctx, targetID, c.sessionID,
)
if len(userChannels) == 0 {
return
@@ -1230,36 +1244,11 @@ func (c *Conn) handleInvite(
targetNick := msg.Params[0]
channel := msg.Params[1]
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
targetID, err := c.database.GetSessionByNick(
ctx, targetNick,
targetID, err := c.svc.InviteUser(
ctx, c.sessionID, c.nick, targetNick, channel,
)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick",
)
c.sendIRCError(err)
return
}
+210
View File
@@ -1239,6 +1239,216 @@ func TestIntegrationModeModerated(t *testing.T) {
)
}
// TestIntegrationModeKey checks that JOIN takes the key of
// a +k channel as its second parameter, and that with
// several channels each key goes to the channel in the
// same position.
func TestIntegrationModeKey(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#keyed")
alice.joinAndDrain("#vault")
// The IRC listener cannot set +k, so the test sets the
// keys directly.
for channel, key := range map[string]string{
"#keyed": "sesame",
"#vault": "barley",
} {
chID, err := env.database.GetChannelByName(
t.Context(), channel,
)
if err != nil {
t.Fatalf("get channel %s: %v", channel, err)
}
err = env.database.SetChannelKey(t.Context(), chID, key)
if err != nil {
t.Fatalf("set key of %s: %v", channel, err)
}
}
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #keyed", " 475 ")
bob.sendAndExpect("JOIN #keyed wrong", " 475 ")
lines := bob.sendAndExpect("JOIN #keyed sesame", " 366 ")
assertContains(
t, lines, "JOIN #keyed", "bob joins with the key",
)
carol := env.dial(t)
carol.register("carol")
lines = carol.sendAndExpect(
"JOIN #keyed,#vault sesame,barley", " 366 carol #vault ",
)
assertContains(t, lines, "JOIN #keyed", "carol joins #keyed")
assertContains(t, lines, "JOIN #vault", "carol joins #vault")
assertNotContains(t, lines, " 475 ", "carol's JOIN")
}
// TestIntegrationModeInviteOnly checks that INVITE lets a
// user into a +i channel.
func TestIntegrationModeInviteOnly(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #invonly", " 473 ")
alice.sendAndExpect("INVITE bob #invonly", " 341 ")
lines := bob.sendAndExpect("JOIN #invonly", " 366 ")
assertContains(
t, lines, "JOIN #invonly", "bob joins once invited",
)
}
// TestIntegrationModeInviteOnlyNeedsOperator checks that on
// a +i channel an INVITE from a member who is not a channel
// operator gets 482 and does not let the invited user in.
func TestIntegrationModeInviteOnlyNeedsOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
// Bob joins before +i is set, so he is a member but
// not an operator.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
carol := env.dial(t)
carol.register("carol")
lines := bob.sendAndExpect("INVITE carol #invonly", " 482 ")
assertContains(
t, lines, " 482 bob #invonly ", "bob's INVITE refused",
)
carol.sendAndExpect("JOIN #invonly", " 473 ")
}
// TestIntegrationInviteUserOnChannel checks that inviting a
// user who is already on the channel gets 443.
func TestIntegrationInviteUserOnChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#room")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#room")
lines := alice.sendAndExpect("INVITE bob #room", " 443 ")
assertContains(
t, lines, " 443 alice bob #room ", "alice's INVITE refused",
)
}
// TestIntegrationModeSecretList checks that LIST shows a +s
// channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobList := bob.sendAndExpect("LIST", " 323 ")
assertContains(
t, bobList, " 322 bob #visible ", "bob's LIST",
)
assertNotContains(t, bobList, "#hidden", "bob's LIST")
aliceList := alice.sendAndExpect("LIST", " 323 ")
assertContains(
t, aliceList, " 322 alice #hidden ", "alice's LIST",
)
}
// TestIntegrationModeSecretWhois checks that WHOIS names a
// +s channel only to users on it.
func TestIntegrationModeSecretWhois(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobWhois := bob.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, bobWhois, "@#visible", "bob's WHOIS")
assertNotContains(t, bobWhois, "#hidden", "bob's WHOIS")
carol := env.dial(t)
carol.register("carol")
carol.joinAndDrain("#hidden")
carolWhois := carol.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, carolWhois, "@#hidden", "carol's WHOIS")
}
// TestIntegrationModeHashcash checks that PRIVMSG to a +H
// channel is refused, since an IRC client cannot attach a
// hashcash stamp.
func TestIntegrationModeHashcash(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#stamped")
alice.sendAndExpect("MODE #stamped +H 2", "+H")
lines := alice.sendAndExpect(
"PRIVMSG #stamped :hello", " 404 ",
)
assertContains(
t, lines, " 404 alice #stamped :Channel requires hashcash",
"PRIVMSG refused",
)
}
// TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from
// the other two.