Files
meshcore-bot/modules/commands/feed_command.py
T
Stacy Olivas 54aeb28bf0 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.
2026-04-14 10:02:36 -07:00

409 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Feed command for the MeshCore Bot
Handles RSS and API feed subscription management
"""
import json
from typing import Optional
from ..models import MeshMessage
from ..security_utils import sanitize_input, sanitize_name, validate_external_url
from .base_command import BaseCommand
class FeedCommand(BaseCommand):
"""Handles feed subscription management"""
# Plugin metadata
name = "feed"
keywords = ['feed', 'feeds', 'rss', 'subscription', 'subscriptions']
description = "Manage RSS and API feed subscriptions (usage: feed subscribe rss <url> <channel> [name])"
category = "admin"
requires_dm = True
cooldown_seconds = 2
requires_internet = True # Requires internet access for RSS/API feed fetching
def __init__(self, bot):
super().__init__(bot)
self.db_path = bot.db_manager.db_path
self.feed_enabled = self.get_config_value('Feed_Command', 'enabled', fallback=True, value_type='bool')
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
"""Check if this command can be executed (enabled, admin only)"""
if not self.feed_enabled:
return False
if not self.requires_admin_access():
return False
return super().can_execute(message)
def requires_admin_access(self) -> bool:
"""Feed command requires admin access"""
return True
async def execute(self, message: MeshMessage) -> bool:
"""Execute the feed command"""
content = message.content.strip()
# Handle exclamation prefix
if content.startswith('!'):
content = content[1:].strip()
# Parse command
parts = content.split()
if len(parts) < 2:
return await self.send_response(message, self.get_help_text())
subcommand = parts[1].lower()
if subcommand == 'subscribe':
return await self._handle_subscribe(message, parts[2:])
elif subcommand == 'unsubscribe':
return await self._handle_unsubscribe(message, parts[2:])
elif subcommand == 'list':
return await self._handle_list(message, parts[2:])
elif subcommand == 'status':
return await self._handle_status(message, parts[2:])
elif subcommand == 'test':
return await self._handle_test(message, parts[2:])
elif subcommand == 'enable':
return await self._handle_enable_disable(message, parts[2:], True)
elif subcommand == 'disable':
return await self._handle_enable_disable(message, parts[2:], False)
elif subcommand == 'update':
return await self._handle_update(message, parts[2:])
else:
return await self.send_response(message, self.get_help_text())
def get_help_text(self) -> str:
"""Get help text for feed command"""
return """Feed Command Usage:
feed subscribe <rss|api> <url> <channel> [name]
feed unsubscribe <id|url> <channel>
feed list [channel]
feed status <id>
feed test <url>
feed enable <id>
feed disable <id>
feed update <id> [interval_seconds]
Examples:
feed subscribe rss https://alerts.example.com/rss emergency "Emergency Alerts"
feed subscribe api https://api.example.com/alerts emergency "API Alerts" '{"headers": {"Authorization": "Bearer TOKEN"}}'
feed list
feed status 1"""
async def _handle_subscribe(self, message: MeshMessage, args: list[str]) -> bool:
"""Handle feed subscribe command"""
if len(args) < 3:
return await self.send_response(message, "Usage: feed subscribe <rss|api> <url> <channel> [name] [api_config]")
feed_type = args[0].lower()
if feed_type not in ['rss', 'api']:
return await self.send_response(message, "Feed type must be 'rss' or 'api'")
feed_url = args[1]
channel_name = sanitize_name(args[2], max_length=64)
feed_name = sanitize_input(args[3], max_length=100) if len(args) > 3 else None
api_config = args[4] if len(args) > 4 and feed_type == 'api' else None
# Validate URL — full SSRF protection (IP range + DNS check)
if not validate_external_url(feed_url):
return await self.send_response(message, "Invalid or unsafe URL")
# Validate channel exists
channel_num = self.bot.channel_manager.get_channel_number(channel_name)
if channel_num is None:
return await self.send_response(message, f"Channel '{channel_name}' not found. Create it first or use a valid channel name.")
# Parse API config if provided
api_config_json = None
if feed_type == 'api' and api_config:
try:
api_config_json = json.loads(api_config)
except json.JSONDecodeError:
return await self.send_response(message, "Invalid API config JSON")
# Create subscription
try:
feed_id = self._create_subscription(
feed_type=feed_type,
feed_url=feed_url,
channel_name=channel_name,
feed_name=feed_name,
api_config=api_config_json
)
response = f"Subscribed to {feed_type.upper()} feed"
if feed_name:
response += f" '{feed_name}'"
response += f" -> channel: {channel_name} (ID: {feed_id})"
return await self.send_response(message, response)
except Exception as e:
self.logger.error(f"Error creating subscription: {e}")
return await self.send_response(message, f"Error creating subscription: {str(e)}")
async def _handle_unsubscribe(self, message: MeshMessage, args: list[str]) -> bool:
"""Handle feed unsubscribe command"""
if len(args) < 1:
return await self.send_response(message, "Usage: feed unsubscribe <id|url> [channel]")
identifier = args[0]
channel_name = args[1] if len(args) > 1 else None
try:
# Try as ID first
try:
feed_id = int(identifier)
success = self._delete_subscription_by_id(feed_id)
except ValueError:
# Try as URL
if channel_name:
success = self._delete_subscription_by_url(identifier, channel_name)
else:
return await self.send_response(message, "Channel name required when using URL")
if success:
return await self.send_response(message, f"Unsubscribed from feed (ID: {identifier})")
else:
return await self.send_response(message, "Feed subscription not found")
except Exception as e:
self.logger.error(f"Error unsubscribing: {e}")
return await self.send_response(message, f"Error unsubscribing: {str(e)}")
async def _handle_list(self, message: MeshMessage, args: list[str]) -> bool:
"""Handle feed list command"""
channel_filter = args[0] if args else None
try:
feeds = self._get_subscriptions(channel_filter)
if not feeds:
response = "No feed subscriptions"
if channel_filter:
response += f" for channel '{channel_filter}'"
return await self.send_response(message, response)
response = f"Feed Subscriptions ({len(feeds)}):\n"
for feed in feeds[:10]: # Limit to 10 for mesh message
status = "enabled" if feed['enabled'] else "disabled"
name = feed.get('feed_name') or feed['feed_url'][:30]
response += f"{feed['id']}. {name} ({feed['feed_type']}) -> {feed['channel_name']} [{status}]\n"
if len(feeds) > 10:
response += f"({len(feeds) - 10} more...)"
return await self.send_response(message, response)
except Exception as e:
self.logger.error(f"Error listing feeds: {e}")
return await self.send_response(message, f"Error listing feeds: {str(e)}")
async def _handle_status(self, message: MeshMessage, args: list[str]) -> bool:
"""Handle feed status command"""
if not args:
return await self.send_response(message, "Usage: feed status <id>")
try:
feed_id = int(args[0])
feed = self._get_subscription_by_id(feed_id)
if not feed:
return await self.send_response(message, f"Feed subscription {feed_id} not found")
status = "enabled" if feed['enabled'] else "disabled"
last_check = feed.get('last_check_time') or "Never"
last_item = feed.get('last_item_id') or "None"
response = f"Feed {feed_id} Status:\n"
response += f"Name: {feed.get('feed_name') or 'N/A'}\n"
response += f"Type: {feed['feed_type']}\n"
response += f"URL: {feed['feed_url']}\n"
response += f"Channel: {feed['channel_name']}\n"
response += f"Status: {status}\n"
response += f"Interval: {feed.get('check_interval_seconds', 300)}s\n"
response += f"Last check: {last_check}\n"
response += f"Last item: {last_item[:30] if last_item != 'None' else 'None'}"
return await self.send_response(message, response)
except ValueError:
return await self.send_response(message, "Invalid feed ID")
except Exception as e:
self.logger.error(f"Error getting feed status: {e}")
return await self.send_response(message, f"Error getting feed status: {str(e)}")
async def _handle_test(self, message: MeshMessage, args: list[str]) -> bool:
"""Handle feed test command"""
if not args:
return await self.send_response(message, "Usage: feed test <url>")
feed_url = args[0]
if not validate_external_url(feed_url):
return await self.send_response(message, "Invalid or unsafe URL")
# Test would require feed_manager to be available
# For now, just validate URL
return await self.send_response(message, f"URL validated: {feed_url}\n(Full test requires feed manager)")
async def _handle_enable_disable(self, message: MeshMessage, args: list[str], enable: bool) -> bool:
"""Handle enable/disable command"""
if not args:
return await self.send_response(message, f"Usage: feed {'enable' if enable else 'disable'} <id>")
try:
feed_id = int(args[0])
success = self._set_subscription_enabled(feed_id, enable)
if success:
status = "enabled" if enable else "disabled"
return await self.send_response(message, f"Feed {feed_id} {status}")
else:
return await self.send_response(message, f"Feed subscription {feed_id} not found")
except ValueError:
return await self.send_response(message, "Invalid feed ID")
except Exception as e:
self.logger.error(f"Error setting feed status: {e}")
return await self.send_response(message, f"Error: {str(e)}")
async def _handle_update(self, message: MeshMessage, args: list[str]) -> bool:
"""Handle update command"""
if not args:
return await self.send_response(message, "Usage: feed update <id> [interval_seconds]")
try:
feed_id = int(args[0])
interval = int(args[1]) if len(args) > 1 else None
success = self._update_subscription(feed_id, interval)
if success:
response = f"Feed {feed_id} updated"
if interval:
response += f" (interval: {interval}s)"
return await self.send_response(message, response)
else:
return await self.send_response(message, f"Feed subscription {feed_id} not found")
except ValueError:
return await self.send_response(message, "Invalid feed ID or interval")
except Exception as e:
self.logger.error(f"Error updating feed: {e}")
return await self.send_response(message, f"Error: {str(e)}")
def _create_subscription(self, feed_type: str, feed_url: str, channel_name: str,
feed_name: Optional[str] = None, api_config: Optional[dict] = None) -> int:
"""Create a new feed subscription"""
with self.bot.db_manager.connection() as conn:
cursor = conn.cursor()
# Get default check interval
default_interval = self.bot.config.getint('Feed_Manager', 'default_check_interval_seconds', fallback=300)
api_config_str = json.dumps(api_config) if api_config else None
cursor.execute('''
INSERT INTO feed_subscriptions
(feed_type, feed_url, channel_name, feed_name, check_interval_seconds, api_config)
VALUES (?, ?, ?, ?, ?, ?)
''', (feed_type, feed_url, channel_name, feed_name, default_interval, api_config_str))
conn.commit()
return cursor.lastrowid
def _delete_subscription_by_id(self, feed_id: int) -> bool:
"""Delete subscription by ID"""
with self.bot.db_manager.connection() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM feed_subscriptions WHERE id = ?', (feed_id,))
conn.commit()
return cursor.rowcount > 0
def _delete_subscription_by_url(self, feed_url: str, channel_name: str) -> bool:
"""Delete subscription by URL and channel"""
with self.bot.db_manager.connection() as conn:
cursor = conn.cursor()
cursor.execute('''
DELETE FROM feed_subscriptions
WHERE feed_url = ? AND channel_name = ?
''', (feed_url, channel_name))
conn.commit()
return cursor.rowcount > 0
def _get_subscriptions(self, channel_filter: Optional[str] = None) -> list[dict]:
"""Get all subscriptions, optionally filtered by channel"""
import sqlite3
with self.bot.db_manager.connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if channel_filter:
cursor.execute('''
SELECT * FROM feed_subscriptions
WHERE channel_name = ?
ORDER BY id
''', (channel_filter,))
else:
cursor.execute('''
SELECT * FROM feed_subscriptions
ORDER BY id
''')
rows = cursor.fetchall()
return [dict(row) for row in rows]
def _get_subscription_by_id(self, feed_id: int) -> Optional[dict]:
"""Get subscription by ID"""
import sqlite3
with self.bot.db_manager.connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM feed_subscriptions WHERE id = ?', (feed_id,))
row = cursor.fetchone()
return dict(row) if row else None
def _set_subscription_enabled(self, feed_id: int, enabled: bool) -> bool:
"""Enable or disable a subscription"""
with self.bot.db_manager.connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE feed_subscriptions
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (1 if enabled else 0, feed_id))
conn.commit()
return cursor.rowcount > 0
def _update_subscription(self, feed_id: int, interval: Optional[int] = None) -> bool:
"""Update subscription settings"""
with self.bot.db_manager.connection() as conn:
cursor = conn.cursor()
if interval:
cursor.execute('''
UPDATE feed_subscriptions
SET check_interval_seconds = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (interval, feed_id))
else:
cursor.execute('''
UPDATE feed_subscriptions
SET updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (feed_id,))
conn.commit()
return cursor.rowcount > 0