Skip to content

Commit 4f55846

Browse files
committed
fix: secure auto-trigger and thread-menu callback execution
Replaces bot impersonation and command-check swapping with an allowlisted server-side reply dispatcher. Callbacks are revalidated at execution, while bot-authored and invalid permission contexts now fail closed. Iv yet to add other commands rather then just reply commands.
1 parent d5fa99b commit 4f55846

6 files changed

Lines changed: 302 additions & 197 deletions

File tree

bot.py

Lines changed: 191 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,17 @@
3232
except ImportError:
3333
pass
3434

35-
from core import checks
3635
from core.changelog import Changelog
3736
from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient
3837
from core.config import ConfigManager
3938
from core.models import (
4039
DMDisabled,
40+
DummyMessage,
4141
HostingMethod,
4242
InvalidConfigError,
4343
PermissionLevel,
4444
SafeFormatter,
45+
UnseenFormatter,
4546
configure_logging,
4647
getLogger,
4748
)
@@ -59,6 +60,21 @@
5960

6061
logger = getLogger(__name__)
6162

63+
64+
# Automation callbacks are recipient-triggered, even though their text is
65+
# configured by staff. Keep this list deliberately small and dispatch these
66+
# actions through Thread.reply instead of the command framework.
67+
AUTOMATION_REPLY_COMMANDS = {
68+
"reply": (False, False, False),
69+
"freply": (True, False, False),
70+
"fareply": (True, True, False),
71+
"fpreply": (True, False, True),
72+
"fpareply": (True, True, True),
73+
"areply": (False, True, False),
74+
"preply": (False, False, True),
75+
"pareply": (False, True, True),
76+
}
77+
6278
temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp")
6379
if not os.path.exists(temp_dir):
6480
os.mkdir(temp_dir)
@@ -1363,59 +1379,192 @@ async def get_contexts(self, message, *, cls=commands.Context):
13631379

13641380
return [ctx]
13651381

1366-
async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context):
1367-
message.author = self.modmail_guild.me
1368-
message.channel = channel
1369-
message.guild = channel.guild
1382+
def resolve_automation_callback(
1383+
self, callback: str
1384+
) -> typing.Optional[typing.List[typing.Tuple[str, str]]]:
1385+
"""Resolve a configured callback into explicitly supported thread actions.
13701386
1371-
view = StringView(message.content)
1372-
ctx = cls(prefix=self.prefix, view=view, bot=self, message=message)
1373-
thread = await self.threads.find(channel=ctx.channel)
1387+
Snippets and aliases remain supported, but aliases are expanded and every
1388+
resulting action is checked each time it runs. This prevents a safe alias
1389+
from being retargeted to a privileged command after configuration.
1390+
"""
1391+
if not isinstance(callback, str):
1392+
return None
13741393

1375-
invoked_prefix = self.prefix
1376-
invoker = None
1394+
actions: typing.List[typing.Tuple[str, str]] = []
13771395

1378-
if self.config.get("use_regex_autotrigger"):
1379-
trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys()))
1380-
if trigger:
1381-
invoker = re.search(trigger, message.content).group(0)
1382-
else:
1383-
trigger = next(
1384-
filter(
1385-
lambda x: x.lower() in message.content.lower(),
1386-
self.auto_triggers.keys(),
1387-
)
1396+
def resolve_invocation(invocation: str, seen_aliases: typing.FrozenSet[str]) -> bool:
1397+
invocation = invocation.strip()
1398+
if not invocation:
1399+
return False
1400+
1401+
if invocation in self.snippets:
1402+
command = self._get_snippet_command()
1403+
command_name = getattr(command, "qualified_name", None)
1404+
snippet = self.snippets[invocation]
1405+
if command_name not in AUTOMATION_REPLY_COMMANDS or not isinstance(snippet, str):
1406+
return False
1407+
actions.append((command_name, snippet))
1408+
return len(actions) <= 25
1409+
1410+
parts = invocation.split(maxsplit=1)
1411+
invoker = parts[0].lower()
1412+
arguments = parts[1] if len(parts) == 2 else ""
1413+
1414+
alias = self.aliases.get(invoker)
1415+
if alias is not None:
1416+
if not isinstance(alias, str) or invoker in seen_aliases or len(seen_aliases) >= 25:
1417+
return False
1418+
expanded = normalize_alias(alias, arguments)
1419+
if not expanded:
1420+
return False
1421+
next_seen = seen_aliases | {invoker}
1422+
return all(resolve_invocation(value, next_seen) for value in expanded)
1423+
1424+
command = self.all_commands.get(invoker)
1425+
command_name = getattr(command, "qualified_name", None)
1426+
if command_name not in AUTOMATION_REPLY_COMMANDS:
1427+
return False
1428+
1429+
actions.append((command_name, arguments))
1430+
return len(actions) <= 25
1431+
1432+
invocations = normalize_alias(callback)
1433+
if not invocations or not all(resolve_invocation(value, frozenset()) for value in invocations):
1434+
return None
1435+
return actions
1436+
1437+
def is_automation_callback_safe(self, callback: str) -> bool:
1438+
"""Return whether a callback resolves only to supported automation actions."""
1439+
return self.resolve_automation_callback(callback) is not None
1440+
1441+
@staticmethod
1442+
def _copy_automation_message(message):
1443+
"""Copy a source message without carrying recipient-provided payloads into a reply."""
1444+
while isinstance(message, DummyMessage):
1445+
message = message._message
1446+
if message is None:
1447+
return None
1448+
message = DummyMessage(copy.copy(message))
1449+
message.attachments = []
1450+
message.embeds = []
1451+
message.stickers = []
1452+
message.message_snapshots = []
1453+
return message
1454+
1455+
def _format_automation_content(self, command_name: str, content: str, thread) -> str:
1456+
formatted, _, _ = AUTOMATION_REPLY_COMMANDS[command_name]
1457+
if command_name == "reply" and self.args:
1458+
return UnseenFormatter().format(content, **self.args)
1459+
if formatted:
1460+
system_author = getattr(self.modmail_guild, "me", None) or self.user
1461+
return self.formatter.format(
1462+
content,
1463+
**self.args,
1464+
channel=thread.channel,
1465+
recipient=thread.recipient,
1466+
author=system_author,
13881467
)
1389-
if trigger:
1390-
invoker = trigger.lower()
1468+
return content
13911469

1392-
alias = self.auto_triggers[trigger]
1470+
async def execute_thread_automation(self, thread, callback: str, message, *, source: str) -> bool:
1471+
"""Execute a recipient-triggered callback through the safe server action path."""
1472+
actions = self.resolve_automation_callback(callback)
1473+
if actions is None:
1474+
logger.warning("Blocked unsafe or invalid %s callback.", source)
1475+
return False
13931476

1394-
ctxs = []
1477+
prepared_actions = []
1478+
for command_name, content in actions:
1479+
try:
1480+
content = self._format_automation_content(command_name, content, thread)
1481+
except Exception:
1482+
logger.warning("Failed to format %s callback.", source, exc_info=True)
1483+
return False
13951484

1396-
if alias is not None:
1397-
ctxs = []
1398-
aliases = normalize_alias(alias)
1399-
if not aliases:
1400-
logger.warning("Alias %s is invalid as called in autotrigger.", invoker)
1485+
if not content:
1486+
logger.warning("Blocked empty %s reply callback.", source)
1487+
return False
1488+
if len(content) > 4096:
1489+
logger.warning("Blocked overlong %s reply callback.", source)
1490+
return False
14011491

1402-
message.author = thread.recipient # Allow for get_contexts to work
1492+
_, anonymous, plain = AUTOMATION_REPLY_COMMANDS[command_name]
1493+
prepared_actions.append((content, anonymous, plain))
14031494

1404-
for alias in aliases:
1405-
message.content = invoked_prefix + alias
1406-
ctxs += await self.get_contexts(message)
1495+
system_author = getattr(self.modmail_guild, "me", None) or self.user
1496+
for content, anonymous, plain in prepared_actions:
1497+
system_message = self._copy_automation_message(message)
1498+
if system_message is None:
1499+
logger.warning("Cannot execute %s callback without a source message.", source)
1500+
return False
14071501

1408-
message.author = self.modmail_guild.me # Fix message so commands execute properly
1502+
system_message.author = system_author
1503+
system_message.channel = thread.channel
1504+
system_message.guild = getattr(thread.channel, "guild", None)
1505+
system_message.content = content
1506+
system_message._automation_source = source
1507+
system_message._automation_triggered_by = thread.recipient
14091508

1410-
for ctx in ctxs:
1411-
if ctx.command:
1412-
old_checks = copy.copy(ctx.command.checks)
1413-
ctx.command.checks = [checks.has_permissions(PermissionLevel.INVALID)]
1509+
try:
1510+
await thread.reply(system_message, content, anonymous=anonymous, plain=plain)
1511+
except Exception:
1512+
logger.warning("Failed to execute %s reply callback.", source, exc_info=True)
1513+
return False
14141514

1415-
await self.invoke(ctx)
1515+
return True
14161516

1417-
ctx.command.checks = old_checks
1418-
continue
1517+
def match_auto_trigger(self, content: str) -> typing.Optional[str]:
1518+
"""Return the first configured autotrigger matching ``content``."""
1519+
if not isinstance(content, str):
1520+
return None
1521+
1522+
if self.config.get("use_regex_autotrigger"):
1523+
for candidate in self.auto_triggers:
1524+
if not isinstance(candidate, str):
1525+
logger.warning("Ignoring non-string autotrigger key %r.", candidate)
1526+
continue
1527+
try:
1528+
if re.search(candidate, content):
1529+
return candidate
1530+
except re.error:
1531+
logger.warning("Ignoring invalid autotrigger regex %r.", candidate)
1532+
return None
1533+
1534+
content = content.casefold()
1535+
return next(
1536+
(
1537+
candidate
1538+
for candidate in self.auto_triggers
1539+
if isinstance(candidate, str) and candidate.casefold() in content
1540+
),
1541+
None,
1542+
)
1543+
1544+
async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context):
1545+
del cls # Kept in the public signature for compatibility with existing extensions.
1546+
1547+
trigger = self.match_auto_trigger(message.content)
1548+
1549+
if trigger is None:
1550+
return False
1551+
1552+
callback = self.auto_triggers.get(trigger)
1553+
if not callback:
1554+
logger.warning("Autotrigger %r has an empty callback.", trigger)
1555+
return False
1556+
1557+
thread = await self.threads.find(channel=channel)
1558+
if thread is None:
1559+
logger.warning("Autotrigger %r fired without a matching thread.", trigger)
1560+
return False
1561+
1562+
return await self.execute_thread_automation(
1563+
thread,
1564+
callback,
1565+
message,
1566+
source=f"autotrigger {trigger!r}",
1567+
)
14191568

14201569
async def get_context(self, message, *, cls=commands.Context):
14211570
"""
@@ -1525,10 +1674,9 @@ async def process_commands(self, message):
15251674
if ctx.command:
15261675
if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")):
15271676
logger.debug(
1528-
"Command %s has no permissions check, adding invalid level.",
1677+
"Command %s has no Modmail permission level; using its declared checks.",
15291678
ctx.command.qualified_name,
15301679
)
1531-
checks.has_permissions(PermissionLevel.INVALID)(ctx.command)
15321680

15331681
# Check if thread is unsnoozing and queue command if so
15341682
thread = await self.threads.find(channel=ctx.channel)

0 commit comments

Comments
 (0)