security: SSRF hardening, log injection sanitization, and allow_local_smtp

Add SSRF host validation to maintenance.py send_nightly_email and
scheduler.py send_zombie_alert_email using validate_external_url().
New allow_local_smtp config key permits private-IP SMTP for local
relay setups.

Add sanitize_name() to security_utils and apply it to all log calls
in message_handler, repeater_manager, path_command, solarforecast_command,
command_manager, and discord_bridge_service to prevent log injection.

Move nightly email logic from duplicate scheduler._send_nightly_email()
into the canonical maintenance.py implementation, removing the duplicate.
Update tests to call maintenance.send_nightly_email() directly.

Add validate_external_url allow_private parameter with support for
loopback, RFC1918, CGN, and link-local address ranges.
This commit is contained in:
Stacy Olivas
2026-04-14 10:02:36 -07:00
committed by agessaman
parent c7fa0ba3d2
commit 54aeb28bf0
18 changed files with 707 additions and 80 deletions
+11 -1
View File
@@ -22,6 +22,7 @@ from .config_validation import (
)
from .models import CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD, MeshMessage
from .plugin_loader import PluginLoader
from .security_utils import sanitize_name, validate_safe_path
from .utils import check_internet_connectivity_async, decode_escape_sequences, format_keyword_response_with_placeholders
@@ -857,6 +858,15 @@ class CommandManager:
self.logger.warning(f"RandomLine matched '{key}' but missing config file.{key}")
return None
try:
validated_path = validate_safe_path(file_path, allow_absolute=True)
except ValueError:
validated_path = None
if validated_path is None:
self.logger.warning(f"RandomLine: unsafe or restricted path rejected for '{key}': {file_path}")
return None
file_path = str(validated_path)
# Read usable lines
try:
with open(file_path, encoding="utf-8") as f:
@@ -952,7 +962,7 @@ class CommandManager:
# Use the contact name for logging
contact_name = contact.get('name', contact.get('adv_name', recipient_id))
self.logger.info(f"Sending DM to {contact_name}: {content}")
self.logger.info("Sending DM to %s", sanitize_name(contact_name))
# Record transmission for repeat tracking (don't let this block sending)
try: