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
This commit is contained in:
Michael H 2018-08-24 09:50:38 -04:00 committed by Toby Harradine
parent 6ebfdef025
commit 77944e195a
8 changed files with 176 additions and 7 deletions

1
.github/CODEOWNERS vendored
View File

@ -25,6 +25,7 @@ redbot/core/utils/data_converter.py @mikeshardmind
redbot/core/utils/antispam.py @mikeshardmind redbot/core/utils/antispam.py @mikeshardmind
redbot/core/utils/tunnel.py @mikeshardmind redbot/core/utils/tunnel.py @mikeshardmind
redbot/core/utils/caching.py @mikeshardmind redbot/core/utils/caching.py @mikeshardmind
redbot/core/utils/common_filters.py @mikeshardmind
# Cogs # Cogs
redbot/cogs/admin/* @tekulvw redbot/cogs/admin/* @tekulvw

View File

@ -45,3 +45,9 @@ Tunnel
.. automodule:: redbot.core.utils.tunnel .. automodule:: redbot.core.utils.tunnel
:members: Tunnel :members: Tunnel
Common Filters
==============
.. automodule:: redbot.core.utils.common_filters
:members:

View File

@ -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 redbot.core.utils.mod import is_mod_or_superior, is_allowed_by_hierarchy, get_audit_reason
from .log import log from .log import log
from redbot.core.utils.common_filters import filter_invites
_ = Translator("Mod", __file__) _ = Translator("Mod", __file__)
@ -1321,9 +1323,11 @@ class Mod:
if roles is not None: if roles is not None:
data.add_field(name=_("Roles"), value=roles, inline=False) data.add_field(name=_("Roles"), value=roles, inline=False)
if names: 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: 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: if voice_state and voice_state.channel:
data.add_field( data.add_field(
name=_("Current voice channel"), name=_("Current voice channel"),
@ -1334,6 +1338,7 @@ class Mod:
name = str(user) name = str(user)
name = " ~ ".join((name, user.nick)) if user.nick else name name = " ~ ".join((name, user.nick)) if user.nick else name
name = filter_invites(name)
if user.avatar: if user.avatar:
avatar = user.avatar_url avatar = user.avatar_url

View File

@ -22,6 +22,7 @@ from . import Config, i18n, commands
from .rpc import RPCMixin from .rpc import RPCMixin
from .help_formatter import Help, help as help_ from .help_formatter import Help, help as help_
from .sentry import SentryManager from .sentry import SentryManager
from .utils import common_filters
def _is_submodule(parent, child): def _is_submodule(parent, child):
@ -292,6 +293,39 @@ class RedBase(BotBase, RPCMixin):
if pkg_name.startswith("redbot.cogs."): if pkg_name.startswith("redbot.cogs."):
del sys.modules["redbot.cogs"].__dict__[name] 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): def add_cog(self, cog):
for attr in dir(cog): for attr in dir(cog):
_attr = getattr(cog, attr) _attr = getattr(cog, attr)

View File

@ -5,7 +5,7 @@ import discord
from discord.ext import commands from discord.ext import commands
from redbot.core.utils.chat_formatting import box from redbot.core.utils.chat_formatting import box
from redbot.core.utils import common_filters
TICK = "\N{WHITE HEAVY CHECK MARK}" TICK = "\N{WHITE HEAVY CHECK MARK}"
@ -20,6 +20,42 @@ class Context(commands.Context):
This class inherits from `discord.ext.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]: async def send_help(self) -> List[discord.Message]:
"""Send the command help message. """Send the command help message.

View File

@ -7,6 +7,8 @@ import discord
from redbot.core import Config from redbot.core import Config
from redbot.core.bot import Red from redbot.core.bot import Red
from .utils.common_filters import filter_invites, filter_mass_mentions, filter_urls
__all__ = [ __all__ = [
"Case", "Case",
"CaseType", "CaseType",
@ -141,7 +143,9 @@ class Case:
datetime.fromtimestamp(self.modified_at).strftime("%Y-%m-%d %H:%M:%S") 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: if embed:
emb = discord.Embed(title=title, description=reason) emb = discord.Embed(title=title, description=reason)
@ -160,6 +164,7 @@ class Case:
emb.timestamp = datetime.fromtimestamp(self.created_at) emb.timestamp = datetime.fromtimestamp(self.created_at)
return emb return emb
else: else:
user = filter_mass_mentions(filter_urls(user)) # Further sanitization outside embeds
case_text = "" case_text = ""
case_text += "{}\n".format(title) case_text += "{}\n".format(title)
case_text += "**User:** {}\n".format(user) case_text += "**User:** {}\n".format(user)

View File

@ -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)

View File

@ -5,6 +5,7 @@ import io
import sys import sys
import weakref import weakref
from typing import List from typing import List
from .common_filters import filter_mass_mentions
_instances = weakref.WeakValueDictionary({}) _instances = weakref.WeakValueDictionary({})
@ -70,10 +71,10 @@ class Tunnel(metaclass=TunnelMeta):
self.recipient = recipient self.recipient = recipient
self.last_interaction = datetime.utcnow() 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 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) 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 @property
def members(self): def members(self):