package security import ( "context" "fmt" "sort" "strings" "unicode" "unicode/utf8" "velox-bot/internal/db/repos/securityrepo" "github.com/bwmarrin/discordgo" ) // Tuning the thresholds, filters and actions. Everything the Join Gate, raid // mode, anti-nuke and heat do can be adjusted from Discord with these calls; // a dashboard is never needed. They follow the same rules as the rest of the // configuration: the server owner or someone on the trusted list, checked // here and not left to Discord's command permissions. const ( maxWords = 50 // blocked words per list maxWordLength = 50 ) var ( filterActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true} botActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true} raidActions = map[string]bool{securityrepo.ActionAlert: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true} heatActions = map[string]bool{securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true} strictnessSet = map[string]bool{"low": true, "medium": true, "high": true} nukeLimitKeys = map[string]bool{ securityrepo.LimitChannelDelete: true, securityrepo.LimitChannelCreate: true, securityrepo.LimitRoleDelete: true, securityrepo.LimitRoleCreate: true, securityrepo.LimitBan: true, securityrepo.LimitKick: true, securityrepo.LimitWebhookCreate: true, } ) // CleanWords trims, lowercases and de-duplicates a list of blocked words and // rejects anything oversized or containing control characters. They are // matched as plain text, never as regular expressions. func CleanWords(in []string) ([]string, error) { seen := map[string]bool{} out := []string{} for _, w := range in { w = strings.ToLower(strings.TrimSpace(w)) if w == "" || seen[w] { continue } if utf8.RuneCountInString(w) > maxWordLength { return nil, UserError("Each blocked word can be at most 50 characters.") } for _, r := range w { if unicode.IsControl(r) { return nil, UserError("Blocked words can't contain control characters.") } } seen[w] = true out = append(out, w) } if len(out) > maxWords { return nil, UserError("You can block at most 50 words.") } sort.Strings(out) return out, nil } func inRange(v *int, lo, hi int) bool { return v == nil || (*v >= lo && *v <= hi) } func oneOf(v *string, allowed map[string]bool) bool { return v == nil || allowed[*v] } // ValidateTuning returns a message for the first invalid field, or "". It // also cleans the word lists in place. func ValidateTuning(t *securityrepo.Tuning) error { for name, v := range map[string]*string{ "No avatar": t.NoAvatar, "New account": t.NewAccount, "Invite in name": t.AdvertisingName, "Blocked words": t.UsernameFilter, } { if !oneOf(v, filterActions) { return UserError(name + " can be off, timeout, kick or ban.") } } if !oneOf(t.UnverifiedBot, botActions) || !oneOf(t.UnauthorizedBot, botActions) { return UserError("Bot filters can only be off, kick or ban.") } if !oneOf(t.RaidAction, raidActions) { return UserError("The raid action can be alert, timeout, kick or ban.") } if !oneOf(t.HeatStrictness, strictnessSet) { return UserError("Heat strictness must be low, medium or high.") } if !oneOf(t.HeatAction, heatActions) { return UserError("The heat action can be timeout, kick or ban.") } switch { case !inRange(t.MinAccountAgeDays, 1, 365): return UserError("Minimum account age must be between 1 and 365 days.") case !inRange(t.RaidJoinCount, 3, 100): return UserError("The raid join count must be between 3 and 100.") case !inRange(t.RaidJoinSeconds, 5, 300): return UserError("The raid window must be between 5 and 300 seconds.") case !inRange(t.RaidQuietMinutes, 1, 120): return UserError("The raid quiet period must be between 1 and 120 minutes.") case !inRange(t.AntiNukeWindowSeconds, 5, 300): return UserError("The anti-nuke window must be between 5 and 300 seconds.") case !inRange(t.HeatTimeoutMinutes, 1, 10080): return UserError("The heat timeout must be between 1 minute and 7 days (10080 minutes).") case !inRange(t.HeatPanicMembers, 0, 50): return UserError("Heat panic must be between 0 (off) and 50 members.") case t.HeatLockdownMentions != nil && *t.HeatLockdownMentions != 0 && (*t.HeatLockdownMentions < 5 || *t.HeatLockdownMentions > 1000): return UserError("The mention flood lockdown must be 0 (off) or between 5 and 1000 mentions.") } for k, v := range t.AntiNukeLimits { if !nukeLimitKeys[k] { return UserError("Unknown anti-nuke limit " + fmt.Sprintf("%q", k) + ".") } if v < 0 || v > 100 { return UserError("Anti-nuke limits must be between 0 and 100 (0 means never allowed).") } } if t.UsernamePatterns != nil { words, err := CleanWords(*t.UsernamePatterns) if err != nil { return err } t.UsernamePatterns = &words } if t.HeatBlockedWords != nil { words, err := CleanWords(*t.HeatBlockedWords) if err != nil { return err } t.HeatBlockedWords = &words } return nil } // Tune changes thresholds, filters and actions. Only the server owner or // someone on the trusted list may. func (e *Engine) Tune(ctx context.Context, guildID string, actor *discordgo.Member, t securityrepo.Tuning, summary string) error { if err := e.RequireTrusted(ctx, guildID, actor); err != nil { return err } if err := ValidateTuning(&t); err != nil { return err } if err := e.st.UpdateTuning(ctx, parseID(guildID), t); err != nil { return err } e.Invalidate(guildID) e.event(ctx, guildID, "settings", actor.User.ID, "", summary) return nil } // WordList names one of the two blocked word lists. type WordList string const ( UsernameWords WordList = "username" // checked against new members' names by the Join Gate ChatWords WordList = "chat" // checked against messages by heat ) // EditWords adds or removes one blocked word and returns the list as saved. func (e *Engine) EditWords(ctx context.Context, guildID string, actor *discordgo.Member, list WordList, add bool, word string) ([]string, error) { if err := e.RequireTrusted(ctx, guildID, actor); err != nil { return nil, err } e.Invalidate(guildID) // read what is saved now, not what was cached cur, _, err := e.load(ctx, guildID) if err != nil { return nil, err } var words []string switch list { case UsernameWords: words = append(words, cur.UsernamePatterns...) case ChatWords: words = append(words, cur.HeatBlockedWords...) default: return nil, UserError("Unknown list.") } word = strings.ToLower(strings.TrimSpace(word)) if word == "" { return nil, UserError("Give a word to block.") } if add { words = append(words, word) } else { kept := words[:0] for _, w := range words { if w != word { kept = append(kept, w) } } if len(kept) == len(words) { return nil, UserError("That word isn't on the list.") } words = kept } clean, err := CleanWords(words) if err != nil { return nil, err } t := securityrepo.Tuning{} if list == UsernameWords { t.UsernamePatterns = &clean } else { t.HeatBlockedWords = &clean } verb := "removed" if add { verb = "added" } if err := e.Tune(ctx, guildID, actor, t, fmt.Sprintf("%s word list: %s %q", list, verb, word)); err != nil { return nil, err } return clean, nil }