Repository: python-discord/bot

Issue:
Sanitize bot output
## The problem
Right now, it is possible to exploit certain parts of the bot in order to make the bot post things a regular member would normally not be allowed to post.

These techniques can be used to circumvent our filters, which is somewhat problematic.

For example, by using eval to build a string containing a discord guild invite, you can circumvent our guild invite filter.
![image](https://user-images.githubusercontent.com/2098517/86953460-c2edcf00-c154-11ea-8091-402a41b24e7b.png)

You can also do this by exploiting the fact that our custom exception format will repeat the input
![image](https://user-images.githubusercontent.com/2098517/86953541-dd27ad00-c154-11ea-9927-3bcecbb3435c.png)

## Running filters over eval output
To fix the exploit involving `!eval`, we should change the `!eval` command so that it will not return anything that triggers a filter.

Let's change the Snekbox cog so that we will do the following:
- Call snekbox and get the output
- **Run the output through all the active filters.**
- If a filter is triggered, do not post the output. Instead post a message that says something like `Attempt to circumvent filter detected. Moderator team has been alerted.`

To do this, we will probably also need to make some changes to the Filtering cog so that we can run filters over an arbitrary string.

## Changing the custom exception output
Currently when we return our custom exceptions, we repeat the input that failed. The simplest solution is not to do this.

Let's simply change this code so that the exception output does not repeat the input, but otherwise looks more or less the same.


Discussion:
I can do this
Okay @slushiegoose, who are you on Discord?
slushiegoose#7362
okay, I've assigned you. Let us know in #dev-contrib if you need any assistance.
I was thinking we could create an utility function, or a global filter on all messages for this. There are a couple more cogs - like `Doc`, which output the user's input on fails.
Perhaps something could also be added to contributing so it's checked for every new feature
> which output the user's input on fails

This is going to be removed
I mean, the proposal here is to change the _global_ custom exception handler, that should apply to all cogs, as long as they are using this method of delivering exceptions. Which they should be.
> > which output the user's input on fails
> 
> This is going to be removed

The doc cog outputs its own embeds instead of raising `BadArgument`s when the requested symbol is not found
Then it would probably be better to make the Doc cog raise BadArguments.

The problem with running all bot messages through filters is that this would need a bunch of exceptions, like our infraction search command, mod-log and mod-alerts embeds, and so on. I think it's better if we just try to generalize exceptions around the BadArguments approach.
Due to BadArgument's formatting and the fact that discord.py's own converters' error messages actually contain the argument swithin it, I personally think the easiest way is to run the BadArgument error message through the filtering. Would love to hear other people's opinions on this.
Just remove the string formatting entirely.
Will that not affect any other BadArgument raises?
Yes
> Then it would probably be better to make the Doc cog raise BadArguments.

This is obviously one of the solutions, but I'm not sure if it's the best one considering the error handler output.
As an example, the user supplies a (valid) symbol to the cog, which fails to be found in the inventories or fails to be parsed by the cog; if this is handled with BadArgument then a mostly unhelpful and irrelevant command help is sent alongside the message passed to BadArgument, while the user did invoke the command correctly
We're going too far into the weeds. There is an immediate need to go through all instances where the bot directly pipes the user's input back out and make this not happen.

If for some reason we have so many cogs doing their own thing with error embeds then we can investigate some utility, monkeypatch, or whatever in another PR. Let's get the immediate problem fixed ASAP. In pretty much every instance this is solved by just removing whatever string formatting exists, and running eval input through the existing filters.
@sco1 is right that we need to get this solved ASAP. Also, I've tested the `!doc` command now, but it doesn't seem like this command can be used to circumvent the filters very effectively. It does include the input, but it includes that input inside a code block in an embed, so for instance:

![image](https://user-images.githubusercontent.com/2098517/86960032-3694d980-c15f-11ea-97ac-16fdf24cc30e.png)

A user who attempts this will trigger the filters with their initial message, and the bot will not generate an invite embed. Furthermore, the exception and invocation are automatically removed after 5-10 seconds. If they managed to use it for mentions, they would not ping. the worst they could do is get the bot to post some bad words that will be automatically removed seconds later, and the mods will be automatically notified. not exactly a disaster.

so I'm not sure this will be a big problem.
Another solution to this problem is not processing commands if a message is flagged however practically that may be hard to implement as it would involve overwriting the core `on_message`

Resolving patch:
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -170,7 +170,7 @@ async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError)
             await prepared_help_command
             self.bot.stats.incr("errors.too_many_arguments")
         elif isinstance(e, errors.BadArgument):
-            await ctx.send(f"Bad argument: {e}\n")
+            await ctx.send("Bad argument: Please double-check your input arguments and try again.\n")
             await prepared_help_command
             self.bot.stats.incr("errors.bad_argument")
         elif isinstance(e, errors.BadUnionArgument):
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -2,7 +2,7 @@
 import logging
 import re
 from datetime import datetime, timedelta
-from typing import List, Mapping, Optional, Union
+from typing import List, Mapping, Optional, Tuple, Union
 
 import dateutil
 import discord.errors
@@ -200,24 +200,67 @@ async def check_bad_words_in_name(self, member: Member) -> None:
             # Update time when alert sent
             await self.name_alerts.set(member.id, datetime.utcnow().timestamp())
 
-    async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
-        """Filter the input message to see if it violates any of our rules, and then respond accordingly."""
+    async def filter_eval(self, result: str, msg: Message) -> bool:
+        """
+        Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly.
+
+        Also requires the original message, to check whether to filter and for mod logs.
+        Returns whether a filter was triggered or not.
+        """
+        filter_triggered = False
         # Should we filter this message?
-        role_whitelisted = False
+        if self._check_filter(msg):
+            for filter_name, _filter in self.filters.items():
+                # Is this specific filter enabled in the config?
+                # We also do not need to worry about filters that take the full message,
+                # since all we have is an arbitrary string.
+                if _filter["enabled"] and _filter["content_only"]:
+                    match = await _filter["function"](result)
 
-        if type(msg.author) is Member:  # Only Member has roles, not User.
-            for role in msg.author.roles:
-                if role.id in Filter.role_whitelist:
-                    role_whitelisted = True
+                    if match:
+                        # If this is a filter (not a watchlist), we set the variable so we know
+                        # that it has been triggered
+                        if _filter["type"] == "filter":
+                            filter_triggered = True
 
-        filter_message = (
-            msg.channel.id not in Filter.channel_whitelist  # Channel not in whitelist
-            and not role_whitelisted                        # Role not in whitelist
-            and not msg.author.bot                          # Author not a bot
-        )
+                        # We do not have to check against DM channels since !eval cannot be used there.
+                        channel_str = f"in {msg.channel.mention}"
+
+                        message_content, additional_embeds, additional_embeds_msg = self._add_stats(
+                            filter_name, match, result
+                        )
+
+                        message = (
+                            f"The {filter_name} {_filter['type']} was triggered "
+                            f"by **{msg.author}** "
+                            f"(`{msg.author.id}`) {channel_str} using !eval with "
+                            f"[the following message]({msg.jump_url}):\n\n"
+                            f"{message_content}"
+                        )
+
+                        log.debug(message)
 
-        # If none of the above, we can start filtering.
-        if filter_message:
+                        # Send pretty mod log embed to mod-alerts
+                        await self.mod_log.send_log_message(
+                            icon_url=Icons.filtering,
+                            colour=Colour(Colours.soft_red),
+                            title=f"{_filter['type'].title()} triggered!",
+                            text=message,
+                            thumbnail=msg.author.avatar_url_as(static_format="png"),
+                            channel_id=Channels.mod_alerts,
+                            ping_everyone=Filter.ping_everyone,
+                            additional_embeds=additional_embeds,
+                            additional_embeds_msg=additional_embeds_msg
+                        )
+
+                        break  # We don't want multiple filters to trigger
+
+        return filter_triggered
+
+    async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
+        """Filter the input message to see if it violates any of our rules, and then respond accordingly."""
+        # Should we filter this message?
+        if self._check_filter(msg):
             for filter_name, _filter in self.filters.items():
                 # Is this specific filter enabled in the config?
                 if _filter["enabled"]:
@@ -276,16 +319,9 @@ async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> No
                         else:
                             channel_str = f"in {msg.channel.mention}"
 
-                        # Word and match stats for watch_regex
-                        if filter_name == "watch_regex":
-                            surroundings = match.string[max(match.start() - 10, 0): match.end() + 10]
-                            message_content = (
-                                f"**Match:** '{match[0]}'\n"
-                                f"**Location:** '...{escape_markdown(surroundings)}...'\n"
-                                f"\n**Original Message:**\n{escape_markdown(msg.content)}"
-                            )
-                        else:  # Use content of discord Message
-                            message_content = msg.content
+                        message_content, additional_embeds, additional_embeds_msg = self._add_stats(
+                            filter_name, match, msg.content
+                        )
 
                         message = (
                             f"The {filter_name} {_filter['type']} was triggered "
@@ -297,30 +333,6 @@ async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> No
 
                         log.debug(message)
 
-                        self.bot.stats.incr(f"filters.{filter_name}")
-
-                        additional_embeds = None
-                        additional_embeds_msg = None
-
-                        # The function returns True for invalid invites.
-                        # They have no data so additional embeds can't be created for them.
-                        if filter_name == "filter_invites" and match is not True:
-                            additional_embeds = []
-                            for invite, data in match.items():
-                                embed = discord.Embed(description=(
-                                    f"**Members:**\n{data['members']}\n"
-                                    f"**Active:**\n{data['active']}"
-                                ))
-                                embed.set_author(name=data["name"])
-                                embed.set_thumbnail(url=data["icon"])
-                                embed.set_footer(text=f"Guild Invite Code: {invite}")
-                                additional_embeds.append(embed)
-                            additional_embeds_msg = "For the following guild(s):"
-
-                        elif filter_name == "watch_rich_embeds":
-                            additional_embeds = msg.embeds
-                            additional_embeds_msg = "With the following embed(s):"
-
                         # Send pretty mod log embed to mod-alerts
                         await self.mod_log.send_log_message(
                             icon_url=Icons.filtering,
@@ -336,6 +348,63 @@ async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> No
 
                         break  # We don't want multiple filters to trigger
 
+    def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[
+        str, Optional[List[discord.Embed]], Optional[str]
+    ]:
+        """Adds relevant statistical information to the relevant filter and increments the bot's stats."""
+        # Word and match stats for watch_regex
+        if name == "watch_regex":
+            surroundings = match.string[max(match.start() - 10, 0): match.end() + 10]
+            message_content = (
+                f"**Match:** '{match[0]}'\n"
+                f"**Location:** '...{escape_markdown(surroundings)}...'\n"
+                f"\n**Original Message:**\n{escape_markdown(content)}"
+            )
+        else:  # Use original content
+            message_content = content
+
+        additional_embeds = None
+        additional_embeds_msg = None
+
+        self.bot.stats.incr(f"filters.{name}")
+
+        # The function returns True for invalid invites.
+        # They have no data so additional embeds can't be created for them.
+        if name == "filter_invites" and match is not True:
+            additional_embeds = []
+            for invite, data in match.items():
+                embed = discord.Embed(description=(
+                    f"**Members:**\n{data['members']}\n"
+                    f"**Active:**\n{data['active']}"
+                ))
+                embed.set_author(name=data["name"])
+                embed.set_thumbnail(url=data["icon"])
+                embed.set_footer(text=f"Guild Invite Code: {invite}")
+                additional_embeds.append(embed)
+            additional_embeds_msg = "For the following guild(s):"
+
+        elif name == "watch_rich_embeds":
+            additional_embeds = match
+            additional_embeds_msg = "With the following embed(s):"
+
+        return message_content, additional_embeds, additional_embeds_msg
+
+    @staticmethod
+    def _check_filter(msg: Message) -> bool:
+        """Check whitelists to see if we should filter this message."""
+        role_whitelisted = False
+
+        if type(msg.author) is Member:  # Only Member has roles, not User.
+            for role in msg.author.roles:
+                if role.id in Filter.role_whitelist:
+                    role_whitelisted = True
+
+        return (
+            msg.channel.id not in Filter.channel_whitelist  # Channel not in whitelist
+            and not role_whitelisted                        # Role not in whitelist
+            and not msg.author.bot                          # Author not a bot
+        )
+
     @staticmethod
     async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]:
         """
@@ -428,7 +497,7 @@ async def _has_invites(self, text: str) -> Union[dict, bool]:
         return invite_data if invite_data else False
 
     @staticmethod
-    async def _has_rich_embed(msg: Message) -> bool:
+    async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]:
         """Determines if `msg` contains any rich embeds not auto-generated from a URL."""
         if msg.embeds:
             for embed in msg.embeds:
@@ -437,7 +506,7 @@ async def _has_rich_embed(msg: Message) -> bool:
                     if not embed.url or embed.url not in urls:
                         # If `embed.url` does not exist or if `embed.url` is not part of the content
                         # of the message, it's unlikely to be an auto-generated embed by Discord.
-                        return True
+                        return msg.embeds
                     else:
                         log.trace(
                             "Found a rich embed sent by a regular user account, "
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -212,7 +212,14 @@ async def send_eval(self, ctx: Context, code: str) -> Message:
             else:
                 self.bot.stats.incr("snekbox.python.success")
 
-            response = await ctx.send(msg)
+            filter_cog = self.bot.get_cog("Filtering")
+            filter_triggered = False
+            if filter_cog:
+                filter_triggered = await filter_cog.filter_eval(msg, ctx.message)
+            if filter_triggered:
+                response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")
+            else:
+                response = await ctx.send(msg)
             self.bot.loop.create_task(
                 wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
             )


Test patch:
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/cogs/test_snekbox.py
@@ -233,6 +233,10 @@ async def test_send_eval(self):
         self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
         self.cog.format_output = AsyncMock(return_value=('[No output]', None))
 
+        mocked_filter_cog = MagicMock()
+        mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+        self.bot.get_cog.return_value = mocked_filter_cog
+
         await self.cog.send_eval(ctx, 'MyAwesomeCode')
         ctx.send.assert_called_once_with(
             '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```'
@@ -254,6 +258,10 @@ async def test_send_eval_with_paste_link(self):
         self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
         self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com'))
 
+        mocked_filter_cog = MagicMock()
+        mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+        self.bot.get_cog.return_value = mocked_filter_cog
+
         await self.cog.send_eval(ctx, 'MyAwesomeCode')
         ctx.send.assert_called_once_with(
             '@LemonLemonishBeard#0042 :yay!: Return code 0.'
@@ -275,6 +283,10 @@ async def test_send_eval_with_non_zero_eval(self):
         self.cog.get_status_emoji = MagicMock(return_value=':nope!:')
         self.cog.format_output = AsyncMock()  # This function isn't called
 
+        mocked_filter_cog = MagicMock()
+        mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+        self.bot.get_cog.return_value = mocked_filter_cog
+
         await self.cog.send_eval(ctx, 'MyAwesomeCode')
         ctx.send.assert_called_once_with(
             '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```'