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...");