From 25f4fcba0d2fc4761ff4d8a9fa331a1a49fb0b9b Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 2 May 2025 21:35:48 -0700 Subject: [PATCH] Spam Detection for Hyperlinks on DMs Add spam detection in case a user copy/pastes a hyperlink to everybody on chat via their DMs: * If the same link is copied to many different people within a time window, the user can be kicked from the chat room with a warning. * The server remembers rate limits by username, so if they log back in and continue to spam the same links, they instead receive a temporary chat ban. * The spam threshold, time window and ban hours are configurable in the BareRTC settings.toml. Other fixes: * The front-end will send a "me" update with its current status and video setting in the 'onLoggedIn' handler. This should help alleviate rough server reboots when a ton of idle users are online, so they don't spam "me" updates to correct their status once the WhoLists begin to roll in. --- pkg/banned_users.go | 13 ++- pkg/commands.go | 29 +------ pkg/config/config.go | 17 +++- pkg/handlers.go | 46 +++++++++- pkg/spam/dmlinks.go | 195 ++++++++++++++++++++++++++++++++++++++++++ pkg/subscriber.go | 28 ++++++ src/App.vue | 21 +++-- src/lib/ChatClient.js | 2 + 8 files changed, 316 insertions(+), 35 deletions(-) create mode 100644 pkg/spam/dmlinks.go diff --git a/pkg/banned_users.go b/pkg/banned_users.go index 2855f4f..e1eb808 100644 --- a/pkg/banned_users.go +++ b/pkg/banned_users.go @@ -22,7 +22,7 @@ var ( ) // BanUser adds a user to the ban list. -func BanUser(username string, duration time.Duration) { +func (s *Server) BanUser(username string, duration time.Duration) { banListMu.Lock() defer banListMu.Unlock() banList[username] = Ban{ @@ -71,3 +71,14 @@ func IsBanned(username string) bool { } return ok } + +// BanExpires returns the time.Duration string until the user's ban expires. +func BanExpires(username string) string { + banListMu.RLock() + defer banListMu.RUnlock() + ban, ok := banList[username] + if ok { + return time.Until(ban.ExpiresAt).String() + } + return "" +} diff --git a/pkg/commands.go b/pkg/commands.go index 83b074d..88c7cda 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -208,20 +208,8 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) { } else if other.Username == sub.Username { sub.ChatServer("/kick: did you really mean to kick yourself?") } else { - other.ChatServer("You have been kicked from the chat room by %s", sub.Username) - other.SendJSON(messages.Message{ - Action: messages.ActionKick, - }) - other.authenticated = false - other.Username = "" + s.KickUser(other, fmt.Sprintf("You have been kicked from the chat room by %s", sub.Username), false) sub.ChatServer("%s has been kicked from the room", username) - - // Broadcast it to everyone. - s.Broadcast(messages.Message{ - Action: messages.ActionPresence, - Username: username, - Message: messages.PresenceKicked, - }) } } @@ -290,22 +278,11 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) { log.Info("Operator %s bans %s for %d hours", sub.Username, username, duration/time.Hour) // Add them to the ban list. - BanUser(username, duration) + s.BanUser(username, duration) // If the target user is currently online, disconnect them and broadcast the ban to everybody. if other, err := s.GetSubscriber(username); err == nil { - s.Broadcast(messages.Message{ - Action: messages.ActionPresence, - Username: username, - Message: messages.PresenceBanned, - }) - - other.ChatServer("You have been banned from the chat room by %s. You may come back after %d hours.", sub.Username, duration/time.Hour) - other.SendJSON(messages.Message{ - Action: messages.ActionKick, - }) - other.authenticated = false - other.Username = "" + s.KickUser(other, fmt.Sprintf("You have been banned from the chat room by %s. You may come back after %d hours.", sub.Username, duration/time.Hour), true) } sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour) diff --git a/pkg/config/config.go b/pkg/config/config.go index 429d82f..97c5e94 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "html/template" "os" + "time" "git.kirsle.net/apps/barertc/pkg/log" "github.com/google/uuid" @@ -12,7 +13,7 @@ import ( // Version of the config format - when new fields are added, it will attempt // to write the settings.toml to disk so new defaults populate. -var currentVersion = 16 +var currentVersion = 17 // Config for your BareRTC app. type Config struct { @@ -53,6 +54,7 @@ type Config struct { ModerationRule []*ModerationRule DirectMessageHistory DirectMessageHistory + DMLinkSpamProtection DMLinkSpamProtection Strings Strings @@ -79,6 +81,13 @@ type DirectMessageHistory struct { DisclaimerMessage string } +type DMLinkSpamProtection struct { + Enabled bool `toml:"" comment:"Spam detection in case a user pastes the same URL into several DM threads in a short timeframe,\ne.g. trying to take your entire userbase to a competing chat room."` + MaxThreads int `toml:"" comment:"The max number of DM threads the user can paste the same link into"` + TimeLimit time.Duration `toml:"" comment:"Rate limit (in seconds), if the user spams MaxThreads within the rate limit they are kicked or banned from chat."` + BanHours time.Duration `toml:"" comment:"The user will be kicked the first time, if they log back in and spam once more they are banned for this many hours."` +} + // GetChannels returns a JavaScript safe array of the default PublicChannels. func (c Config) GetChannels() template.JS { data, _ := json.Marshal(c.PublicChannels) @@ -238,6 +247,12 @@ func DefaultConfig() Config { RetentionDays: 90, DisclaimerMessage: ` Reminder: please conduct yourself honorably in Direct Messages.`, }, + DMLinkSpamProtection: DMLinkSpamProtection{ + Enabled: true, + MaxThreads: 5, + TimeLimit: 60 * 60, + BanHours: 24, + }, Logging: Logging{ Directory: "./logs", Channels: []string{"lobby", "offtopic"}, diff --git a/pkg/handlers.go b/pkg/handlers.go index 2d0da8c..0b37bca 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -12,6 +12,7 @@ import ( "git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/messages" "git.kirsle.net/apps/barertc/pkg/models" + "git.kirsle.net/apps/barertc/pkg/spam" "git.kirsle.net/apps/barertc/pkg/util" ) @@ -78,8 +79,9 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) { // Is the username currently banned? if IsBanned(msg.Username) { sub.ChatServer( - "You are currently banned from entering the chat room. Chat room bans are temporarily and usually last for " + - "24 hours. Please try coming back later.", + "You are currently banned from entering the chat room. Chat room bans are temporary and usually last for "+ + "24 hours.

Your ban expires in: %s", + BanExpires(msg.Username), ) sub.SendJSON(messages.Message{ Action: messages.ActionKick, @@ -197,6 +199,46 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) { // Is this a DM? if strings.HasPrefix(msg.Channel, "@") { + + // Check the message for hyperlink spam: if one user is copy/pasting a link to too many others + // over DMs (possibly trying to evade detection from moderators), kick or ban them from chat. + if err := spam.LinkSpamManager.Check(sub.Username, msg.Channel, msg.Message); err != nil { + log.Error("Username %s has spammed a link too often (most recently to %s): %s", sub.Username, msg.Channel, msg.Message) + + var ( + isBan = err == spam.ErrLinkSpamBanUser + + // For the admin report. + report = "The user was **kicked** from the room with a warning." + username = sub.Username + ) + if isBan { + report = "The user was **banned** from chat." + s.BanUser(sub.Username, config.Current.DMLinkSpamProtection.BanHours*time.Hour) + } + + s.KickUser(sub, err.Error(), isBan) + + // Send an admin report to your main website. + if err := PostWebhookReport(WebhookRequestReport{ + FromUsername: username, + AboutUsername: username, + Channel: msg.Channel, + Timestamp: time.Now().Format(time.RFC3339), + Reason: "Spam Detected", + Message: fmt.Sprintf( + "_In a DM to:_ %s
[%s] %s", + msg.Channel, + username, + msg.Message, + ), + Comment: report, + }); err != nil { + log.Error("Error delivering a report to your website about the /nsfw command by %s: %s", sub.Username, err) + } + return + } + // Echo the message only to both parties. s.SendTo(sub.Username, message) message.Channel = "@" + sub.Username diff --git a/pkg/spam/dmlinks.go b/pkg/spam/dmlinks.go new file mode 100644 index 0000000..95337f2 --- /dev/null +++ b/pkg/spam/dmlinks.go @@ -0,0 +1,195 @@ +package spam + +import ( + "crypto/sha256" + "errors" + "fmt" + "regexp" + "sync" + "time" + + "git.kirsle.net/apps/barertc/pkg/config" +) + +/* +LinkSpamMap keeps track of link spamming behavior per username. + +It is a map of usernames to their recent history of hyperlinks sent to other usernames +over DMs. The intention is to detect when one user is spamming (copy/pasting) the same +hyperlink to many many people over DMs, e.g., if they are trying to take the whole chat +room away to a competing video conference and they are sending the link by DM to everybody +on chat in order to hide from the moderators by not using the public channels. +*/ +type LinkSpamMap map[string]UserLinkMap + +// LinkSpam holds info about possibly spammy hyperlinks that Username has sent to multiple +// others over Direct Messages, to detect e.g. somebody spamming a link to their off-site +// video conference to everybody on chat while hiding from moderators and public channels. +type LinkSpam struct { + Username string + URL string + SentTo map[string]struct{} // Usernames they have sent it to + FirstSent time.Time // time of the first link + LastSent time.Time // time of the most recently sent link + Lock sync.RWMutex + Kicked bool // user was kicked once for this spam +} + +// UserLinkMap connects usernames to the set of distinct links they have sent. +// +// It is a map of the URL hash to the LinkSpam data struct. +type UserLinkMap map[string]*LinkSpam + +// LinkSpamManager is the singleton global instance variable that checks and tracks link +// spam sent by users on chat. It is initialized at server startup and provides an API +// surface area to ping and test for spammy behavior from users. +var ( + LinkSpamManager LinkSpamMap + linkSpamLock sync.Mutex // protects the top-level map from concurrent writes. +) + +var HyperlinkRegexp = regexp.MustCompile(`(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)`) + +func init() { + LinkSpamManager = map[string]UserLinkMap{} + go LinkSpamManager.expire() +} + +/* +Check if the current user has been spamming a link to too many people over DMs. + +This function will parse the message for any hyperlinks, and upsert/ping the +sourceUsername's spam detection struct. + +If the user has pasted the same hyperlink into too many different DM threads, +this function may return one of two sentinel errors: + +- ErrLinkSpamKickUser if the user should be kicked from the room. +- ErrLinkSpamBanUser if the user should be banned from the room. + +The first time they trip the spam limit they are to be kicked, but if they rejoin +the chat and (still within the spam duration window), paste the same link to yet +another recipient they will be banned temporarily from chat. +*/ +func (m LinkSpamMap) Check(sourceUsername, targetUsername, message string) error { + + if !config.Current.DMLinkSpamProtection.Enabled { + return nil + } + + linkSpamLock.Lock() + defer linkSpamLock.Unlock() + + // Initialize data structures. + if _, ok := m[sourceUsername]; !ok { + m[sourceUsername] = UserLinkMap{} + } + + // Parse all URLs from their message. + matches := HyperlinkRegexp.FindAllStringSubmatch(message, -1) + for _, match := range matches { + var ( + url = match[0] + hash = Hash([]byte(url)) + ) + + // Initialize the struct? + if _, ok := m[sourceUsername][hash]; !ok { + m[sourceUsername][hash] = &LinkSpam{ + Username: sourceUsername, + URL: url, + SentTo: map[string]struct{}{}, + FirstSent: time.Now(), + } + } + + // Check and update information. + spam := m[sourceUsername][hash] + spam.Lock.Lock() + defer spam.Lock.Unlock() + + spam.SentTo[targetUsername] = struct{}{} + spam.LastSent = time.Now() + + // Have they sent it to too many people? + if len(spam.SentTo) > config.Current.DMLinkSpamProtection.MaxThreads { + // Kick or ban them. + if spam.Kicked { + return ErrLinkSpamBanUser + } + spam.Kicked = true + return ErrLinkSpamKickUser + } + } + + return nil +} + +// expire cleans up link spam data after the rate limit window for them had passed. +// +// It runs as a background goroutine and periodically cleans up expired link spam. +func (m LinkSpamMap) expire() { + for { + time.Sleep(5 * time.Minute) + + // Lock the top-level struct for cleanup. + linkSpamLock.Lock() + + // Iterate all users who have links stored. + var cleanupUsernames = []string{} + for username, links := range m { + var cleanupHashes = []string{} + for hash, spam := range links { + // Has this record expired based on its LastSent time? + if time.Since(spam.LastSent) > config.Current.DMLinkSpamProtection.TimeLimit*time.Second { + cleanupHashes = append(cleanupHashes, hash) + } + } + + // Clean up the hashes. + if len(cleanupHashes) > 0 { + for _, hash := range cleanupHashes { + delete(links, hash) + } + } + + // Are any left anymore? + if len(links) == 0 { + cleanupUsernames = append(cleanupUsernames, username) + } + } + + // Clean up empty usernames? + if len(cleanupUsernames) > 0 { + for _, username := range cleanupUsernames { + delete(m, username) + } + } + + // Unlock the struct. + linkSpamLock.Unlock() + } +} + +// Sentinel errors returned from LinkSpamMan.Check(). +var ( + ErrLinkSpamKickUser = errors.New( + `Spam Detected: ` + + `You have pasted the same URL link to too many different people in a row, and this has been flagged as spam.

` + + `You will now be kicked from the chat room. You may refresh and log back in, however, if you continue to spam this ` + + `link one more time, you will be temporarily banned from the chat room.`, + ) + ErrLinkSpamBanUser = errors.New( + `Spam Detected: ` + + `You recently were kicked from the chat room because you had already pasted this link to too many different people. ` + + `You were warned that spamming this link one more time would result in a temporary ban from the chat room.

` + + `You are now (temporarily) banned from the chat room.`, + ) +) + +// Hash a byte array as SHA256 and returns the hex string. +func Hash(input []byte) string { + h := sha256.New() + h.Write(input) + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/pkg/subscriber.go b/pkg/subscriber.go index 8a26b1d..7352b05 100644 --- a/pkg/subscriber.go +++ b/pkg/subscriber.go @@ -431,6 +431,34 @@ func (s *Server) SendTo(username string, msg messages.Message) error { return nil } +// KickUser kicks a user from chat with a message. +func (s *Server) KickUser(sub *Subscriber, message string, banned bool) { + // Broadcast it to everyone. + if banned { + s.Broadcast(messages.Message{ + Action: messages.ActionPresence, + Username: sub.Username, + Message: messages.PresenceBanned, + }) + } else { + s.Broadcast(messages.Message{ + Action: messages.ActionPresence, + Username: sub.Username, + Message: messages.PresenceKicked, + }) + } + + // Tell the user they were kicked. + sub.ChatServer(message) + sub.SendJSON(messages.Message{ + Action: messages.ActionKick, + }) + sub.authenticated = false + sub.Username = "" + + s.SendWhoList() +} + // SendWhoList broadcasts the connected members to everybody in the room. func (s *Server) SendWhoList() { diff --git a/src/App.vue b/src/App.vue index 57a34a5..f069f21 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1048,14 +1048,15 @@ export default { // Sync the current user state (such as video broadcasting status) to // the backend, which will reload everybody's Who List. - sendMe() { - if (!this.connected) return; - this.client.send({ + sendMe(force) { + if (!this.connected && !force) return; + let payload = { action: "me", video: this.myVideoFlag, status: this.status, dnd: this.prefs.closeDMs, - }); + }; + this.client.send(payload); }, onMe(msg) { // We have had settings pushed to us by the server, such as a change @@ -1141,7 +1142,14 @@ export default { // Do we need to set our me status again? if (sendMe) { - this.sendMe(); + // TODO: Delay a random period before sending the 'me' action, to help alleviate rough server reboots. + // Ideally: onLoggedIn() sends a "me" with the user's status/video setting immediately. On a server + // reboot, the first WhoList is sent 15 seconds after boot, and should (does!) send the correct "status" + // flags for each user online (so they don't need to re-send "me" to correct it, triggering an exponential + // flurry of WhoList updates). Randomize between 1 and 10 seconds. + // The bug seems fixed but this timeout is a safety net. + let timeout = 1000 + parseInt(Math.random() * 10000) + setTimeout(this.sendMe, timeout); } }, @@ -1448,6 +1456,9 @@ export default { if (this.webcam.autoshare) { this.startVideo({ force: true }); } + + // Send the server our current status and video setting. + this.sendMe(true); }, /** diff --git a/src/lib/ChatClient.js b/src/lib/ChatClient.js index 7da9102..9bd527a 100644 --- a/src/lib/ChatClient.js +++ b/src/lib/ChatClient.js @@ -247,6 +247,8 @@ class ChatClient { // Dial the WebSocket. dial() { + this.firstMe = false; + // Polling API? if (this.usePolling) { this.ChatClient("Connecting to the server via polling API...");