From 42d246b557dcb73e4877a49d915abb6ac931d124 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 25 Oct 2020 20:40:41 -0400 Subject: [PATCH 1/2] improve security properties of INVITE See #1171. --- default.yaml | 4 ++++ irc/channel.go | 29 ++++++++++++++++++++--------- irc/client.go | 35 +++++++++++++++++++++++++++-------- irc/config.go | 3 ++- traditional.yaml | 4 ++++ 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/default.yaml b/default.yaml index c404a71a..90ac339c 100644 --- a/default.yaml +++ b/default.yaml @@ -556,6 +556,10 @@ channels: # than this value will get an empty response to /LIST (a time period of 0 disables) list-delay: 0s + # INVITE to an invite-only channel expires after this amount of time + # (0 or omit for no expiration): + invite-expiration: 24h + # operator classes oper-classes: # local operator diff --git a/irc/channel.go b/irc/channel.go index 878d73c6..7304654d 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -677,6 +677,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp chname := channel.name chcfname := channel.nameCasefolded founder := channel.registeredFounder + createdAt := channel.createdTime chkey := channel.key limit := channel.userLimit chcount := len(channel.members) @@ -695,7 +696,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp // 3. people invited with INVITE can join hasPrivs := isSajoin || (founder != "" && founder == details.account) || (persistentMode != 0 && persistentMode != modes.Voice) || - client.CheckInvited(chcfname) + client.CheckInvited(chcfname, createdAt) if !hasPrivs { if limit != 0 && chcount >= limit { return errLimitExceeded @@ -1475,23 +1476,33 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb // Invite invites the given client to the channel, if the inviter can do so. func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuffer) { - chname := channel.Name() - if channel.flags.HasMode(modes.InviteOnly) && !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { - rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, inviter.Nick(), chname, inviter.t("You're not a channel operator")) - return - } + channel.stateMutex.RLock() + chname := channel.name + chcfname := channel.nameCasefolded + createdAt := channel.createdTime + _, inviterPresent := channel.members[inviter] + _, inviteePresent := channel.members[invitee] + channel.stateMutex.RUnlock() - if !channel.hasClient(inviter) { + if !inviterPresent { rb.Add(nil, inviter.server.name, ERR_NOTONCHANNEL, inviter.Nick(), chname, inviter.t("You're not on that channel")) return } - if channel.hasClient(invitee) { + inviteOnly := channel.flags.HasMode(modes.InviteOnly) + if inviteOnly && !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { + rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, inviter.Nick(), chname, inviter.t("You're not a channel operator")) + return + } + + if inviteePresent { rb.Add(nil, inviter.server.name, ERR_USERONCHANNEL, inviter.Nick(), invitee.Nick(), chname, inviter.t("User is already on that channel")) return } - invitee.Invite(channel.NameCasefolded()) + if inviteOnly { + invitee.Invite(chcfname, createdAt) + } for _, member := range channel.Members() { if member == inviter || member == invitee || !channel.ClientIsAtLeast(member, modes.Halfop) { diff --git a/irc/client.go b/irc/client.go index c4920f37..018918a5 100644 --- a/irc/client.go +++ b/irc/client.go @@ -84,7 +84,7 @@ type Client struct { destroyed bool modes modes.ModeSet hostname string - invitedTo utils.StringSet + invitedTo map[string]channelInvite isSTSOnly bool languages []string lastActive time.Time // last time they sent a command that wasn't PONG or similar @@ -1764,26 +1764,45 @@ func (client *Client) removeChannel(channel *Channel) { } } +type channelInvite struct { + channelCreatedAt time.Time + invitedAt time.Time +} + // Records that the client has been invited to join an invite-only channel -func (client *Client) Invite(casefoldedChannel string) { +func (client *Client) Invite(casefoldedChannel string, channelCreatedAt time.Time) { + now := time.Now().UTC() client.stateMutex.Lock() defer client.stateMutex.Unlock() if client.invitedTo == nil { - client.invitedTo = make(utils.StringSet) + client.invitedTo = make(map[string]channelInvite) } - client.invitedTo.Add(casefoldedChannel) + client.invitedTo[casefoldedChannel] = channelInvite{ + channelCreatedAt: channelCreatedAt, + invitedAt: now, + } + + return } // Checks that the client was invited to join a given channel -func (client *Client) CheckInvited(casefoldedChannel string) (invited bool) { +func (client *Client) CheckInvited(casefoldedChannel string, createdTime time.Time) (invited bool) { + config := client.server.Config() + expTime := time.Duration(config.Channels.InviteExpiration) + now := time.Now().UTC() + client.stateMutex.Lock() defer client.stateMutex.Unlock() - invited = client.invitedTo.Has(casefoldedChannel) - // joining an invited channel "uses up" your invite, so you can't rejoin on kick - delete(client.invitedTo, casefoldedChannel) + curInvite, ok := client.invitedTo[casefoldedChannel] + if ok { + // joining an invited channel "uses up" your invite, so you can't rejoin on kick + delete(client.invitedTo, casefoldedChannel) + } + invited = ok && (expTime == time.Duration(0) || now.Sub(curInvite.invitedAt) < expTime) && + createdTime.Equal(curInvite.channelCreatedAt) return } diff --git a/irc/config.go b/irc/config.go index 502e664f..00356c03 100644 --- a/irc/config.go +++ b/irc/config.go @@ -574,7 +574,8 @@ type Config struct { OperatorOnly bool `yaml:"operator-only"` MaxChannelsPerAccount int `yaml:"max-channels-per-account"` } - ListDelay time.Duration `yaml:"list-delay"` + ListDelay time.Duration `yaml:"list-delay"` + InviteExpiration custime.Duration `yaml:"invite-expiration"` } OperClasses map[string]*OperClassConfig `yaml:"oper-classes"` diff --git a/traditional.yaml b/traditional.yaml index a0134ab4..2fb404ee 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -528,6 +528,10 @@ channels: # than this value will get an empty response to /LIST (a time period of 0 disables) list-delay: 0s + # INVITE to an invite-only channel expires after this amount of time + # (0 or omit for no expiration): + invite-expiration: 24h + # operator classes oper-classes: # local operator From 4b1e6b04c4c61d83de1517fefdcc4c67dbe5f90d Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 25 Oct 2020 22:16:19 -0400 Subject: [PATCH 2/2] add UNINVITE command (#1171) --- irc/channel.go | 16 ++++++++++++++++ irc/client.go | 6 ++++++ irc/commands.go | 4 ++++ irc/handlers.go | 9 ++++++++- irc/help.go | 5 +++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/irc/channel.go b/irc/channel.go index 7304654d..59a80a04 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1524,6 +1524,22 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf } } +// Uninvite rescinds a channel invitation, if the inviter can do so. +func (channel *Channel) Uninvite(invitee *Client, inviter *Client, rb *ResponseBuffer) { + if !channel.flags.HasMode(modes.InviteOnly) { + rb.Add(nil, channel.server.name, "FAIL", "UNINVITE", "NOT_INVITE_ONLY", channel.Name(), inviter.t("Channel is not invite-only")) + return + } + + if !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { + rb.Add(nil, channel.server.name, "FAIL", "UNINVITE", "NOT_PRIVED", channel.Name(), inviter.t("You're not a channel operator")) + return + } + + invitee.Uninvite(channel.NameCasefolded()) + rb.Add(nil, channel.server.name, "UNINVITE", invitee.Nick(), channel.Name()) +} + // returns who the client can "see" in the channel, respecting the auditorium mode func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) { channel.stateMutex.RLock() diff --git a/irc/client.go b/irc/client.go index 018918a5..693d14b8 100644 --- a/irc/client.go +++ b/irc/client.go @@ -1787,6 +1787,12 @@ func (client *Client) Invite(casefoldedChannel string, channelCreatedAt time.Tim return } +func (client *Client) Uninvite(casefoldedChannel string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + delete(client.invitedTo, casefoldedChannel) +} + // Checks that the client was invited to join a given channel func (client *Client) CheckInvited(casefoldedChannel string, createdTime time.Time) (invited bool) { config := client.server.Config() diff --git a/irc/commands.go b/irc/commands.go index c1ec6f41..cb81fd2f 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -324,6 +324,10 @@ func init() { minParams: 1, oper: true, }, + "UNINVITE": { + handler: inviteHandler, + minParams: 2, + }, "UNKLINE": { handler: unKLineHandler, minParams: 1, diff --git a/irc/handlers.go b/irc/handlers.go index 78f34b81..241bbefe 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1113,7 +1113,9 @@ func infoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp } // INVITE +// UNINVITE func inviteHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + invite := msg.Command == "INVITE" nickname := msg.Params[0] channelName := msg.Params[1] @@ -1129,7 +1131,12 @@ func inviteHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re return false } - channel.Invite(target, client, rb) + if invite { + channel.Invite(target, client, rb) + } else { + channel.Uninvite(target, client, rb) + } + return false } diff --git a/irc/help.go b/irc/help.go index c867c31d..5fbd130b 100644 --- a/irc/help.go +++ b/irc/help.go @@ -541,6 +541,11 @@ For example: Used in connection registration, sets your username and realname to the given values (though your username may also be looked up with Ident).`, + }, + "uninvite": { + text: `UNINVITE + +UNINVITE rescinds a channel invitation sent for an invite-only channel.`, }, "users": { text: `USERS [parameters]