/* * InspIRCd -- Internet Relay Chat Daemon * * Copyright (C) 2021 Dominic Hamon * Copyright (C) 2021 David Schultz * Copyright (C) 2018 linuxdaemon * Copyright (C) 2018 Matt Schatz * Copyright (C) 2017-2024 Sadie Powell * Copyright (C) 2013-2014 Attila Molnar * Copyright (C) 2013 Daniel Vassdal * * This file is part of InspIRCd. InspIRCd is free software: you can * redistribute it and/or modify it under the terms of the GNU General Public * License as published by the Free Software Foundation, version 2. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "inspircd.h" #include "extension.h" #include "modules/exemption.h" #include "numerichelper.h" #include "timeutils.h" class ChannelSettings final { public: enum RepeatAction { ACT_BAN, ACT_BLOCK, ACT_KICK, ACT_KICK_BAN, ACT_MUTE, }; RepeatAction Action; unsigned int Backlog; unsigned int Lines; unsigned int Diff; unsigned long Seconds; void serialize(bool extended, std::string& out) const { if (extended) { switch (Action) { case ACT_BAN: out.append("ban"); break; case ACT_BLOCK: out.append("block"); break; case ACT_MUTE: out.append("mute"); break; case ACT_KICK: out.append("kick"); break; case ACT_KICK_BAN: out.append("kickban"); break; } out.push_back(':'); } else { switch (Action) { case ACT_KICK_BAN: out.push_back('*'); break; case ACT_BLOCK: out.push_back('~'); break; default: break; // No other types are supported in the old mode. } } out.append(ConvToStr(Lines)).push_back(':'); out.append(ConvToStr(Seconds)); if (Diff) { out.push_back(':'); out.append(ConvToStr(Diff)); if (Backlog) { out.push_back(':'); out.append(ConvToStr(Backlog)); } } } }; class RepeatMode final : public ParamMode> { private: struct RepeatItem final { time_t ts; std::string line; RepeatItem(time_t TS, const std::string& Line) : ts(TS) , line(Line) { } }; typedef std::deque RepeatItemList; struct MemberInfo final { RepeatItemList ItemList; unsigned int Counter = 0; }; struct ModuleSettings final { bool Extended = false; unsigned long MaxLines = 0; unsigned long MaxSecs = 0; unsigned long MaxBacklog = 0; unsigned int MaxDiff = 0; size_t MaxMessageSize = 0; std::string Message; }; std::vector mx[2]; bool CompareLines(const std::string& message, const std::string& historyline, unsigned long trigger) { if (message == historyline) return true; else if (trigger) return (Levenshtein(message, historyline) <= trigger); return false; } size_t Levenshtein(const std::string& s1, const std::string& s2) { size_t l1 = s1.size(); size_t l2 = s2.size(); for (size_t i = 0; i < l2; i++) mx[0][i] = i; for (size_t i = 0; i < l1; i++) { mx[1][0] = i + 1; for (size_t j = 0; j < l2; j++) mx[1][j + 1] = std::min(std::min(mx[1][j] + 1, mx[0][j + 1] + 1), mx[0][j] + ((s1[i] == s2[j]) ? 0 : 1)); mx[0].swap(mx[1]); } return mx[0][l2]; } public: ModuleSettings ms; SimpleExtItem MemberInfoExt; RepeatMode(Module* Creator) : ParamMode>(Creator, "repeat", 'E') , MemberInfoExt(Creator, "repeat", ExtensionType::MEMBERSHIP) { } void OnUnset(User* source, Channel* chan) override { // Unset the per-membership extension when the mode is removed for (const auto& [_, memb] : chan->GetUsers()) MemberInfoExt.Unset(memb); } bool OnSet(User* source, Channel* channel, std::string& parameter) override { ChannelSettings settings; if (!ParseSettings(source, parameter, settings)) { source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter)); return false; } if ((settings.Backlog > 0) && (settings.Lines > settings.Backlog)) { source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, "You can't set lines higher than backlog.")); return false; } LocalUser* localsource = IS_LOCAL(source); if ((localsource) && (!ValidateSettings(localsource, channel, parameter, settings))) return false; ext.Set(channel, settings); return true; } bool MatchLine(Membership* memb, ChannelSettings* rs, std::string message) { // If the message is larger than whatever size it's set to, // let's pretend it isn't. If the first 512 (def. setting) match, it's probably spam. if (message.size() > ms.MaxMessageSize) message.erase(ms.MaxMessageSize); MemberInfo* rp = MemberInfoExt.Get(memb); if (!rp) { rp = new MemberInfo; MemberInfoExt.Set(memb, rp); } unsigned int matches = 0; if (!rs->Backlog) matches = rp->Counter; RepeatItemList& items = rp->ItemList; const unsigned long trigger = (message.size() * rs->Diff / 100); const time_t now = ServerInstance->Time(); std::transform(message.begin(), message.end(), message.begin(), ::tolower); for (std::deque::iterator it = items.begin(); it != items.end(); ++it) { if (it->ts < now) { items.erase(it, items.end()); matches = 0; break; } if (CompareLines(message, it->line, trigger)) { if (++matches >= rs->Lines) { if (rs->Action != ChannelSettings::ACT_BLOCK) rp->Counter = 0; return true; } } else if ((ms.MaxBacklog == 0) || (rs->Backlog == 0)) { matches = 0; items.clear(); break; } } unsigned int max_items = (rs->Backlog ? rs->Backlog : 1); if (items.size() >= max_items) items.pop_back(); items.push_front(RepeatItem(now + rs->Seconds, message)); rp->Counter = matches; return false; } void Resize(size_t size) { size_t newsize = size+1; if (newsize <= mx[0].size()) return; ms.MaxMessageSize = size; mx[0].resize(newsize); mx[1].resize(newsize); } void SerializeParam(Channel* chan, const ChannelSettings* chset, std::string& out) { chset->serialize(ms.Extended, out); } void SetSyntax() { if (ms.Extended) syntax = "{ban|block|mute|kick|kickban}::[:][:]"; else syntax = "[~|*]:[:][:]"; } private: bool ParseAction(irc::sepstream& stream, ChannelSettings::RepeatAction& action) { std::string actionstr; if (!stream.GetToken(actionstr)) return false; if (irc::equals(actionstr, "ban")) action = ChannelSettings::ACT_BAN; else if (irc::equals(actionstr, "kick")) action = ChannelSettings::ACT_KICK; else if (irc::equals(actionstr, "block")) action = ChannelSettings::ACT_BLOCK; else if (irc::equals(actionstr, "mute")) action = ChannelSettings::ACT_MUTE; else if (irc::equals(actionstr, "kickban")) action = ChannelSettings::ACT_KICK_BAN; else return false; return true; } bool ParseSettings(User* source, std::string& parameter, ChannelSettings& settings) { irc::sepstream stream(parameter, ':'); std::string item; if (ms.Extended) { if (!ParseAction(stream, settings.Action) || !stream.GetToken(item)) return false; } else { if (!stream.GetToken(item)) return false; // Required parameter missing settings.Action = ChannelSettings::ACT_KICK; if ((item[0] == '*') || (item[0] == '~')) { settings.Action = ((item[0] == '*') ? ChannelSettings::ACT_KICK_BAN : ChannelSettings::ACT_BLOCK); item.erase(item.begin()); } } if ((settings.Lines = ConvToNum(item)) == 0) return false; if ((!stream.GetToken(item)) || !Duration::TryFrom(item, settings.Seconds) || (settings.Seconds == 0)) // Required parameter missing return false; // The diff and backlog parameters are optional settings.Diff = settings.Backlog = 0; if (stream.GetToken(item)) { // There is a diff parameter, see if it's valid (> 0) if ((settings.Diff = ConvToNum(item)) == 0) return false; if (stream.GetToken(item)) { // There is a backlog parameter, see if it's valid if ((settings.Backlog = ConvToNum(item)) == 0) return false; // If there are still tokens, then it's invalid because we allow only 4 if (stream.GetToken(item)) return false; } } return true; } bool ValidateSettings(LocalUser* source, Channel* channel, const std::string& parameter, const ChannelSettings& settings) { if (ms.MaxLines && settings.Lines > ms.MaxLines) { source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, INSP_FORMAT( "The line number you specified is too big. Maximum allowed is {}.", ms.MaxLines))); return false; } if (ms.MaxSecs && settings.Seconds > ms.MaxSecs) { source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, INSP_FORMAT( "The seconds you specified are too big. Maximum allowed is {}.", ms.MaxSecs))); return false; } if (settings.Diff && settings.Diff > ms.MaxDiff) { if (ms.MaxDiff == 0) source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, "The server administrator has disabled matching on edit distance.")); else source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, INSP_FORMAT( "The distance you specified is too big. Maximum allowed is {}.", ms.MaxDiff))); return false; } if (settings.Backlog && settings.Backlog > ms.MaxBacklog) { if (ms.MaxBacklog == 0) source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, "The server administrator has disabled backlog matching.")); else source->WriteNumeric(Numerics::InvalidModeParameter(channel, this, parameter, INSP_FORMAT( "The backlog you specified is too big. Maximum allowed is {}.", ms.MaxBacklog))); return false; } return true; } }; class RepeatModule final : public Module { private: ChanModeReference banmode; CheckExemption::EventProvider exemptionprov; RepeatMode rm; void CreateBan(Channel* channel, User* user, bool mute) { std::string banmask(mute ? "m:*!" : "*!"); banmask.append(user->GetBanUser(false)); banmask.append("@"); banmask.append(user->GetDisplayedHost()); Modes::ChangeList changelist; changelist.push_add(*banmode, banmask); ServerInstance->Modes.Process(ServerInstance->FakeClient, channel, nullptr, changelist); } public: RepeatModule() : Module(VF_VENDOR | VF_COMMON, "Adds channel mode E (repeat) which helps protect against spammers which spam the same message repeatedly.") , banmode(this, "ban") , exemptionprov(this) , rm(this) { } void ReadConfig(ConfigStatus& status) override { const auto& tag = ServerInstance->Config->ConfValue("repeat"); rm.ms.Message = tag->getString("message", tag->getString("kickmessage", "Repeat flood detected (trigger is %lines% messages in %duration%)"), 1); rm.ms.Extended = tag->getBool("extended"); rm.ms.MaxBacklog = tag->getNum("maxbacklog", 20); rm.ms.MaxDiff = tag->getNum("maxdistance", 50, 0, 100); rm.ms.MaxLines = tag->getNum("maxlines", 20); rm.ms.MaxSecs = tag->getDuration("maxtime", 0); rm.Resize(tag->getNum("size", 512, 1, ServerInstance->Config->Limits.MaxLine)); } ModResult OnUserPreMessage(User* user, MessageTarget& target, MessageDetails& details) override { if (target.type != MessageTarget::TYPE_CHANNEL || !IS_LOCAL(user)) return MOD_RES_PASSTHRU; auto* chan = target.Get(); ChannelSettings* settings = rm.ext.Get(chan); if (!settings) return MOD_RES_PASSTHRU; Membership* memb = chan->GetUser(user); if (!memb) return MOD_RES_PASSTHRU; ModResult res = exemptionprov.Check(user, chan, "repeat"); if (res == MOD_RES_ALLOW) return MOD_RES_PASSTHRU; if (user->HasPrivPermission("channels/ignore-repeat")) return MOD_RES_PASSTHRU; if (rm.MatchLine(memb, settings, details.text)) { const std::string message = Template::Replace(rm.ms.Message, { { "channel", chan->name }, { "diff", ConvToStr(settings->Diff) }, { "duration", Duration::ToString(settings->Seconds) }, { "lines", ConvToStr(settings->Lines) }, { "seconds", ConvToStr(settings->Seconds) }, }); switch (settings->Action) { case ChannelSettings::ACT_BAN: memb->WriteNotice(message); CreateBan(chan, user, false); break; case ChannelSettings::ACT_BLOCK: memb->WriteNotice(message); break; case ChannelSettings::ACT_KICK: chan->KickUser(ServerInstance->FakeClient, user, message); break; case ChannelSettings::ACT_KICK_BAN: CreateBan(chan, user, false); chan->KickUser(ServerInstance->FakeClient, user, message); break; case ChannelSettings::ACT_MUTE: memb->WriteNotice(message); CreateBan(chan, user, true); break; } return MOD_RES_DENY; } return MOD_RES_PASSTHRU; } void Prioritize() override { ServerInstance->Modules.SetPriority(this, I_OnUserPreMessage, PRIORITY_LAST); } void GetLinkData(LinkData& data, std::string& compatdata) override { data["actions"] = rm.ms.Extended ? "ban block kick kickban mute" : "block kick kickban"; data["max-lines"] = ConvToStr(rm.ms.MaxLines); data["max-secs"] = ConvToStr(rm.ms.MaxSecs); data["max-diff"] = ConvToStr(rm.ms.MaxDiff); data["max-backlog"] = ConvToStr(rm.ms.MaxBacklog); compatdata = INSP_FORMAT("{}{}:{}:{}:{}", rm.ms.Extended ? "extended:" : "", rm.ms.MaxLines, rm.ms.MaxSecs, rm.ms.MaxDiff, rm.ms.MaxBacklog); } }; MODULE_INIT(RepeatModule)