From 42d246b557dcb73e4877a49d915abb6ac931d124 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 25 Oct 2020 20:40:41 -0400 Subject: [PATCH] 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