From 77944e195a5a786efa36dc55a6bd757e9440b0a5 Mon Sep 17 00:00:00 2001 From: Michael H Date: Fri, 24 Aug 2018 09:50:38 -0400 Subject: [PATCH] Output sanitisation (#1942) * Add output sanitization defaults to context.send Add some common regex filters in redbot.core.utils.common_filters Add a wrapper for ease of use in bot.send_filtered Sanitize ModLog Case's user field (other's considered trusted as moderator input) Sanitize Usernames/Nicks in userinfo command. Santize Usernames in closing of tunnels. * Add documentation --- .github/CODEOWNERS | 1 + docs/framework_utils.rst | 8 ++- redbot/cogs/mod/mod.py | 9 +++- redbot/core/bot.py | 34 ++++++++++++ redbot/core/commands/context.py | 38 +++++++++++++- redbot/core/modlog.py | 7 ++- redbot/core/utils/common_filters.py | 81 +++++++++++++++++++++++++++++ redbot/core/utils/tunnel.py | 5 +- 8 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 redbot/core/utils/common_filters.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d0fd93ce4..2cda28058 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,6 +25,7 @@ redbot/core/utils/data_converter.py @mikeshardmind redbot/core/utils/antispam.py @mikeshardmind redbot/core/utils/tunnel.py @mikeshardmind redbot/core/utils/caching.py @mikeshardmind +redbot/core/utils/common_filters.py @mikeshardmind # Cogs redbot/cogs/admin/* @tekulvw diff --git a/docs/framework_utils.rst b/docs/framework_utils.rst index e3ff54688..f9573b05b 100644 --- a/docs/framework_utils.rst +++ b/docs/framework_utils.rst @@ -44,4 +44,10 @@ Tunnel ====== .. automodule:: redbot.core.utils.tunnel - :members: Tunnel \ No newline at end of file + :members: Tunnel + +Common Filters +============== + +.. automodule:: redbot.core.utils.common_filters + :members: diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py index 56545e960..320793501 100644 --- a/redbot/cogs/mod/mod.py +++ b/redbot/cogs/mod/mod.py @@ -12,6 +12,8 @@ from .checks import mod_or_voice_permissions, admin_or_voice_permissions, bot_ha from redbot.core.utils.mod import is_mod_or_superior, is_allowed_by_hierarchy, get_audit_reason from .log import log +from redbot.core.utils.common_filters import filter_invites + _ = Translator("Mod", __file__) @@ -1321,9 +1323,11 @@ class Mod: if roles is not None: data.add_field(name=_("Roles"), value=roles, inline=False) if names: - data.add_field(name=_("Previous Names"), value=", ".join(names), inline=False) + val = filter_invites(", ".join(names)) + data.add_field(name=_("Previous Names"), value=val, inline=False) if nicks: - data.add_field(name=_("Previous Nicknames"), value=", ".join(nicks), inline=False) + val = filter_invites(", ".join(nicks)) + data.add_field(name=_("Previous Nicknames"), value=val, inline=False) if voice_state and voice_state.channel: data.add_field( name=_("Current voice channel"), @@ -1334,6 +1338,7 @@ class Mod: name = str(user) name = " ~ ".join((name, user.nick)) if user.nick else name + name = filter_invites(name) if user.avatar: avatar = user.avatar_url diff --git a/redbot/core/bot.py b/redbot/core/bot.py index fdbc4d85e..d53f994da 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -22,6 +22,7 @@ from . import Config, i18n, commands from .rpc import RPCMixin from .help_formatter import Help, help as help_ from .sentry import SentryManager +from .utils import common_filters def _is_submodule(parent, child): @@ -292,6 +293,39 @@ class RedBase(BotBase, RPCMixin): if pkg_name.startswith("redbot.cogs."): del sys.modules["redbot.cogs"].__dict__[name] + async def send_filtered( + destination: discord.abc.Messageable, + filter_mass_mentions=True, + filter_invite_links=True, + filter_all_links=False, + **kwargs, + ): + """ + This is a convienience wrapper around + + discord.abc.Messageable.send + + It takes the destination you'd like to send to, which filters to apply + (defaults on mass mentions, and invite links) and any other parameters + normally accepted by destination.send + + This should realistically only be used for responding using user provided + input. (unfortunately, including usernames) + Manually crafted messages which dont take any user input have no need of this + """ + + content = kwargs.pop("content", None) + + if content: + if filter_mass_mentions: + content = common_filters.filter_mass_mentions(content) + if filter_invite_links: + content = common_filters.filter_invites(content) + if filter_all_links: + content = common_filters.filter_urls(content) + + await destination.send(content=content, **kwargs) + def add_cog(self, cog): for attr in dir(cog): _attr = getattr(cog, attr) diff --git a/redbot/core/commands/context.py b/redbot/core/commands/context.py index e61f201fb..b15857646 100644 --- a/redbot/core/commands/context.py +++ b/redbot/core/commands/context.py @@ -5,7 +5,7 @@ import discord from discord.ext import commands from redbot.core.utils.chat_formatting import box - +from redbot.core.utils import common_filters TICK = "\N{WHITE HEAVY CHECK MARK}" @@ -20,6 +20,42 @@ class Context(commands.Context): This class inherits from `discord.ext.commands.Context`. """ + async def send(self, content=None, **kwargs): + """Sends a message to the destination with the content given. + + This acts the same as `discord.ext.commands.Context.send`, with + one added keyword argument as detailed below in *Other Parameters*. + + Parameters + ---------- + content : str + The content of the message to send. + + Other Parameters + ---------------- + filter : Callable[`str`] -> `str` + A function which is used to sanitize the ``content`` before + it is sent. Defaults to + :func:`~redbot.core.utils.common_filters.filter_mass_mentions`. + This must take a single `str` as an argument, and return + the sanitized `str`. + \*\*kwargs + See `discord.ext.commands.Context.send`. + + Returns + ------- + discord.Message + The message that was sent. + + """ + + _filter = kwargs.pop("filter", common_filters.filter_mass_mentions) + + if _filter and content: + content = _filter(str(content)) + + return await super().send(content=content, **kwargs) + async def send_help(self) -> List[discord.Message]: """Send the command help message. diff --git a/redbot/core/modlog.py b/redbot/core/modlog.py index f4f8af431..eafe4ec97 100644 --- a/redbot/core/modlog.py +++ b/redbot/core/modlog.py @@ -7,6 +7,8 @@ import discord from redbot.core import Config from redbot.core.bot import Red +from .utils.common_filters import filter_invites, filter_mass_mentions, filter_urls + __all__ = [ "Case", "CaseType", @@ -141,7 +143,9 @@ class Case: datetime.fromtimestamp(self.modified_at).strftime("%Y-%m-%d %H:%M:%S") ) - user = "{}#{} ({})\n".format(self.user.name, self.user.discriminator, self.user.id) + user = filter_invites( + "{}#{} ({})\n".format(self.user.name, self.user.discriminator, self.user.id) + ) # Invites get rendered even in embeds. if embed: emb = discord.Embed(title=title, description=reason) @@ -160,6 +164,7 @@ class Case: emb.timestamp = datetime.fromtimestamp(self.created_at) return emb else: + user = filter_mass_mentions(filter_urls(user)) # Further sanitization outside embeds case_text = "" case_text += "{}\n".format(title) case_text += "**User:** {}\n".format(user) diff --git a/redbot/core/utils/common_filters.py b/redbot/core/utils/common_filters.py new file mode 100644 index 000000000..ca7040e3f --- /dev/null +++ b/redbot/core/utils/common_filters.py @@ -0,0 +1,81 @@ +import re + +__all__ = [ + "URL_RE", + "INVITE_URL_RE", + "MASS_MENTION_RE", + "filter_urls", + "filter_invites", + "filter_mass_mentions", +] + +# regexes +URL_RE = re.compile(r"(https?|s?ftp)://(\S+)", re.I) + +INVITE_URL_RE = re.compile(r"(discord.gg|discordapp.com/invite|discord.me)(\S+)", re.I) + +MASS_MENTION_RE = re.compile(r"(@)(?=everyone|here)") # This only matches the @ for sanitizing + + +# convenience wrappers +def filter_urls(to_filter: str) -> str: + """Get a string with URLs sanitized. + + This will match any URLs starting with these protocols: + + - ``http://`` + - ``https://`` + - ``ftp://`` + - ``sftp://`` + + Parameters + ---------- + to_filter : str + The string to filter. + + Returns + ------- + str + The sanitized string. + + """ + return URL_RE.sub("[SANITIZED URL]", to_filter) + + +def filter_invites(to_filter: str) -> str: + """Get a string with discord invites sanitized. + + Will match any discord.gg, discordapp.com/invite, or discord.me + invite URL. + + Parameters + ---------- + to_filter : str + The string to filter. + + Returns + ------- + str + The sanitized string. + + """ + return INVITE_URL_RE.sub("[SANITIZED INVITE]", to_filter) + + +def filter_mass_mentions(to_filter: str) -> str: + """Get a string with mass mentions sanitized. + + Will match any *here* and/or *everyone* mentions. + + Parameters + ---------- + to_filter : str + The string to filter. + + Returns + ------- + str + The sanitized string. + + """ + return MASS_MENTION_RE.sub("@\u200b", to_filter) diff --git a/redbot/core/utils/tunnel.py b/redbot/core/utils/tunnel.py index 72328c2e9..32684a721 100644 --- a/redbot/core/utils/tunnel.py +++ b/redbot/core/utils/tunnel.py @@ -5,6 +5,7 @@ import io import sys import weakref from typing import List +from .common_filters import filter_mass_mentions _instances = weakref.WeakValueDictionary({}) @@ -70,10 +71,10 @@ class Tunnel(metaclass=TunnelMeta): self.recipient = recipient self.last_interaction = datetime.utcnow() - async def react_close(self, *, uid: int, message: str): + async def react_close(self, *, uid: int, message: str = ""): send_to = self.origin if uid == self.sender.id else self.sender closer = next(filter(lambda x: x.id == uid, (self.sender, self.recipient)), None) - await send_to.send(message.format(closer=closer)) + await send_to.send(filter_mass_mentions(message.format(closer=closer))) @property def members(self):