// ======================================== // Remote Notification Routes // ======================================== // API endpoints for remote notification configuration import type { IncomingMessage, ServerResponse } from 'http'; import { URL } from 'url'; import { loadConfig, saveConfig, resetConfig, } from '../../config/remote-notification-config.js'; import { remoteNotificationService, } from '../services/remote-notification-service.js'; import { maskSensitiveConfig, type RemoteNotificationConfig, type TestNotificationRequest, type NotificationPlatform, type DiscordConfig, type TelegramConfig, type WebhookConfig, type FeishuConfig, type DingTalkConfig, type WeComConfig, type EmailConfig, } from '../../types/remote-notification.js'; import { deepMerge } from '../../types/util.js'; // ========== Input Validation ========== // Compiled regex patterns for performance (compiled once at module load) const TELEGRAM_BOT_TOKEN_REGEX = /^\d{8,15}:[A-Za-z0-9_-]{30,50}$/; const TELEGRAM_CHAT_ID_REGEX = /^-?\d{1,20}$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const DISCORD_HOSTNAMES = ['discord.com', 'discordapp.com']; const FEISHU_HOSTNAMES = ['feishu.cn', 'larksuite.com', 'lark.com']; const DINGTALK_HOSTNAMES = ['dingtalk.com', 'oapi.dingtalk.com']; const WECOM_HOSTNAMES = ['qyapi.weixin.qq.com', 'work.weixin.qq.com']; // Pattern for safe header values - only printable ASCII characters (no control chars) // This prevents XSS by ensuring header values don't contain HTML/JS metacharacters // Space (0x20) through tilde (0x7E) are printable ASCII characters const SAFE_HEADER_VALUE_REGEX = /^[\x20-\x7E]+$/; /** * Validate URL format (must be http or https) */ function isValidUrl(url: string): boolean { try { const parsed = new URL(url); return ['http:', 'https:'].includes(parsed.protocol); } catch { return false; } } /** * Validate Discord webhook URL format */ function isValidDiscordWebhookUrl(url: string): boolean { if (!isValidUrl(url)) return false; try { const parsed = new URL(url); // Discord webhooks are typically: discord.com/api/webhooks/{id}/{token} return ( DISCORD_HOSTNAMES.includes(parsed.hostname) && parsed.pathname.startsWith('/api/webhooks/') ); } catch { return false; } } /** * Validate Telegram bot token format (typically: 123456789:ABCdef...) */ function isValidTelegramBotToken(token: string): boolean { // Telegram bot tokens are in format: {bot_id}:{token} // Bot ID is a number, token is alphanumeric with underscores and hyphens return TELEGRAM_BOT_TOKEN_REGEX.test(token); } /** * Validate Telegram chat ID format */ function isValidTelegramChatId(chatId: string): boolean { // Chat IDs are numeric, optionally negative (for groups) return TELEGRAM_CHAT_ID_REGEX.test(chatId); } /** * Validate webhook headers (must be valid JSON object) */ function isValidHeaders(headers: unknown): { valid: boolean; error?: string } { if (headers === undefined || headers === null) { return { valid: true }; // Optional field } if (typeof headers !== 'object' || Array.isArray(headers)) { return { valid: false, error: 'Headers must be an object' }; } const headerObj = headers as Record; // Check for reasonable size limit (10KB) const serialized = JSON.stringify(headers); if (serialized.length > 10240) { return { valid: false, error: 'Headers too large (max 10KB)' }; } // Validate each header key and value for (const [key, value] of Object.entries(headerObj)) { if (typeof key !== 'string' || key.length === 0) { return { valid: false, error: 'Header keys must be non-empty strings' }; } if (typeof value !== 'string') { return { valid: false, error: `Header '${key}' value must be a string` }; } // Sanitize header value - only allow printable ASCII characters // This prevents XSS by blocking HTML/JS metacharacters and control characters if (!SAFE_HEADER_VALUE_REGEX.test(value)) { return { valid: false, error: `Header '${key}' contains invalid characters. Only printable ASCII characters (space through tilde) are allowed.` }; } // Block potentially dangerous headers const lowerKey = key.toLowerCase(); if (['host', 'content-length', 'connection'].includes(lowerKey)) { return { valid: false, error: `Header '${key}' is not allowed` }; } } return { valid: true }; } /** * Validate Feishu webhook URL format */ function isValidFeishuWebhookUrl(url: string): boolean { if (!isValidUrl(url)) return false; try { const parsed = new URL(url); // Feishu webhooks are typically: open.feishu.cn/open-apis/bot/v2/hook/{token} // or: open.larksuite.com/open-apis/bot/v2/hook/{token} const validHosts = ['open.feishu.cn', 'open.larksuite.com']; return validHosts.includes(parsed.hostname) && parsed.pathname.includes('/bot/'); } catch { return false; } } /** * Validate DingTalk webhook URL format */ function isValidDingTalkWebhookUrl(url: string): boolean { if (!isValidUrl(url)) return false; try { const parsed = new URL(url); // DingTalk webhooks are typically: oapi.dingtalk.com/robot/send?access_token=xxx return DINGTALK_HOSTNAMES.some(h => parsed.hostname.includes(h)) && parsed.pathname.includes('robot'); } catch { return false; } } /** * Validate WeCom webhook URL format */ function isValidWeComWebhookUrl(url: string): boolean { if (!isValidUrl(url)) return false; try { const parsed = new URL(url); // WeCom webhooks are typically: qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx return WECOM_HOSTNAMES.includes(parsed.hostname) && parsed.pathname.includes('webhook'); } catch { return false; } } /** * Validate email address format */ function isValidEmail(email: string): boolean { // Basic email validation regex (using compiled constant) return EMAIL_REGEX.test(email); } /** * Validate SMTP port number */ function isValidSmtpPort(port: number): boolean { return Number.isInteger(port) && port > 0 && port <= 65535; } /** * Validate configuration updates */ function validateConfigUpdates(updates: Partial): { valid: boolean; error?: string } { // Validate platforms if present if (updates.platforms) { const { discord, telegram, webhook, feishu, dingtalk, wecom, email } = updates.platforms; // Validate Discord config if (discord) { if (discord.webhookUrl !== undefined && discord.webhookUrl !== '') { if (!isValidUrl(discord.webhookUrl)) { return { valid: false, error: 'Invalid Discord webhook URL format' }; } // Warning: we allow non-Discord URLs for flexibility, but log it if (!isValidDiscordWebhookUrl(discord.webhookUrl)) { console.warn('[RemoteNotification] Webhook URL does not match Discord format'); } } if (discord.username !== undefined && discord.username.length > 80) { return { valid: false, error: 'Discord username too long (max 80 chars)' }; } } // Validate Telegram config if (telegram) { if (telegram.botToken !== undefined && telegram.botToken !== '') { if (!isValidTelegramBotToken(telegram.botToken)) { return { valid: false, error: 'Invalid Telegram bot token format' }; } } if (telegram.chatId !== undefined && telegram.chatId !== '') { if (!isValidTelegramChatId(telegram.chatId)) { return { valid: false, error: 'Invalid Telegram chat ID format' }; } } } // Validate Webhook config if (webhook) { if (webhook.url !== undefined && webhook.url !== '') { if (!isValidUrl(webhook.url)) { return { valid: false, error: 'Invalid webhook URL format' }; } } if (webhook.headers !== undefined) { const headerValidation = isValidHeaders(webhook.headers); if (!headerValidation.valid) { return { valid: false, error: headerValidation.error }; } } if (webhook.timeout !== undefined && (webhook.timeout < 1000 || webhook.timeout > 60000)) { return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' }; } } // Validate Feishu config if (feishu) { if (feishu.webhookUrl !== undefined && feishu.webhookUrl !== '') { if (!isValidUrl(feishu.webhookUrl)) { return { valid: false, error: 'Invalid Feishu webhook URL format' }; } if (!isValidFeishuWebhookUrl(feishu.webhookUrl)) { console.warn('[RemoteNotification] Webhook URL does not match Feishu format'); } } if (feishu.title !== undefined && feishu.title.length > 100) { return { valid: false, error: 'Feishu title too long (max 100 chars)' }; } } // Validate DingTalk config if (dingtalk) { if (dingtalk.webhookUrl !== undefined && dingtalk.webhookUrl !== '') { if (!isValidUrl(dingtalk.webhookUrl)) { return { valid: false, error: 'Invalid DingTalk webhook URL format' }; } if (!isValidDingTalkWebhookUrl(dingtalk.webhookUrl)) { console.warn('[RemoteNotification] Webhook URL does not match DingTalk format'); } } if (dingtalk.keywords !== undefined) { if (!Array.isArray(dingtalk.keywords)) { return { valid: false, error: 'DingTalk keywords must be an array' }; } if (dingtalk.keywords.length > 10) { return { valid: false, error: 'Too many DingTalk keywords (max 10)' }; } } } // Validate WeCom config if (wecom) { if (wecom.webhookUrl !== undefined && wecom.webhookUrl !== '') { if (!isValidUrl(wecom.webhookUrl)) { return { valid: false, error: 'Invalid WeCom webhook URL format' }; } if (!isValidWeComWebhookUrl(wecom.webhookUrl)) { console.warn('[RemoteNotification] Webhook URL does not match WeCom format'); } } if (wecom.mentionedList !== undefined) { if (!Array.isArray(wecom.mentionedList)) { return { valid: false, error: 'WeCom mentionedList must be an array' }; } if (wecom.mentionedList.length > 100) { return { valid: false, error: 'Too many mentioned users (max 100)' }; } } } // Validate Email config if (email) { if (email.host !== undefined && email.host !== '') { if (email.host.length > 255) { return { valid: false, error: 'Email host too long (max 255 chars)' }; } } if (email.port !== undefined) { if (!isValidSmtpPort(email.port)) { return { valid: false, error: 'Invalid SMTP port (must be 1-65535)' }; } } if (email.username !== undefined && email.username.length > 255) { return { valid: false, error: 'Email username too long (max 255 chars)' }; } if (email.from !== undefined && email.from !== '') { if (!isValidEmail(email.from)) { return { valid: false, error: 'Invalid sender email address' }; } } if (email.to !== undefined) { if (!Array.isArray(email.to)) { return { valid: false, error: 'Email recipients must be an array' }; } if (email.to.length === 0) { return { valid: false, error: 'At least one email recipient is required' }; } if (email.to.length > 50) { return { valid: false, error: 'Too many email recipients (max 50)' }; } for (const addr of email.to) { if (!isValidEmail(addr)) { return { valid: false, error: `Invalid email address: ${addr}` }; } } } } } // Validate timeout if (updates.timeout !== undefined && (updates.timeout < 1000 || updates.timeout > 60000)) { return { valid: false, error: 'Timeout must be between 1000ms and 60000ms' }; } return { valid: true }; } /** * Validate test notification request */ function validateTestRequest(request: TestNotificationRequest): { valid: boolean; error?: string } { if (!request.platform) { return { valid: false, error: 'Missing platform' }; } const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook', 'feishu', 'dingtalk', 'wecom', 'email']; if (!validPlatforms.includes(request.platform as NotificationPlatform)) { return { valid: false, error: `Invalid platform: ${request.platform}` }; } if (!request.config) { return { valid: false, error: 'Missing config' }; } // Platform-specific validation switch (request.platform) { case 'discord': { const config = request.config as Partial; if (!config.webhookUrl) { return { valid: false, error: 'Discord webhook URL is required' }; } if (!isValidUrl(config.webhookUrl)) { return { valid: false, error: 'Invalid Discord webhook URL format' }; } break; } case 'telegram': { const config = request.config as Partial; if (!config.botToken) { return { valid: false, error: 'Telegram bot token is required' }; } if (!config.chatId) { return { valid: false, error: 'Telegram chat ID is required' }; } if (!isValidTelegramBotToken(config.botToken)) { return { valid: false, error: 'Invalid Telegram bot token format' }; } if (!isValidTelegramChatId(config.chatId)) { return { valid: false, error: 'Invalid Telegram chat ID format' }; } break; } case 'webhook': { const config = request.config as Partial; if (!config.url) { return { valid: false, error: 'Webhook URL is required' }; } if (!isValidUrl(config.url)) { return { valid: false, error: 'Invalid webhook URL format' }; } if (config.headers) { const headerValidation = isValidHeaders(config.headers); if (!headerValidation.valid) { return { valid: false, error: headerValidation.error }; } } break; } case 'feishu': { const config = request.config as Partial; if (!config.webhookUrl) { return { valid: false, error: 'Feishu webhook URL is required' }; } if (!isValidUrl(config.webhookUrl)) { return { valid: false, error: 'Invalid Feishu webhook URL format' }; } break; } case 'dingtalk': { const config = request.config as Partial; if (!config.webhookUrl) { return { valid: false, error: 'DingTalk webhook URL is required' }; } if (!isValidUrl(config.webhookUrl)) { return { valid: false, error: 'Invalid DingTalk webhook URL format' }; } break; } case 'wecom': { const config = request.config as Partial; if (!config.webhookUrl) { return { valid: false, error: 'WeCom webhook URL is required' }; } if (!isValidUrl(config.webhookUrl)) { return { valid: false, error: 'Invalid WeCom webhook URL format' }; } break; } case 'email': { const config = request.config as Partial; if (!config.host) { return { valid: false, error: 'SMTP host is required' }; } if (!config.username) { return { valid: false, error: 'SMTP username is required' }; } if (!config.password) { return { valid: false, error: 'SMTP password is required' }; } if (!config.from) { return { valid: false, error: 'Sender email address is required' }; } if (!isValidEmail(config.from)) { return { valid: false, error: 'Invalid sender email address' }; } if (!config.to || config.to.length === 0) { return { valid: false, error: 'At least one recipient email is required' }; } for (const addr of config.to) { if (!isValidEmail(addr)) { return { valid: false, error: `Invalid recipient email: ${addr}` }; } } if (config.port !== undefined && !isValidSmtpPort(config.port)) { return { valid: false, error: 'Invalid SMTP port' }; } break; } } return { valid: true }; } /** * Handle remote notification routes * GET /api/notifications/remote/config - Get current config * POST /api/notifications/remote/config - Update config * POST /api/notifications/remote/test - Test notification * POST /api/notifications/remote/reset - Reset to defaults */ export async function handleNotificationRoutes( req: IncomingMessage, res: ServerResponse, pathname: string ): Promise { // GET /api/notifications/remote/config if (pathname === '/api/notifications/remote/config' && req.method === 'GET') { const config = loadConfig(); const masked = maskSensitiveConfig(config); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(masked)); return true; } // POST /api/notifications/remote/config if (pathname === '/api/notifications/remote/config' && req.method === 'POST') { const body = await readBody(req); try { const updates = JSON.parse(body) as Partial; // Validate input const validation = validateConfigUpdates(updates); if (!validation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: validation.error })); return true; } const current = loadConfig(); const updated = deepMerge(current, updates); saveConfig(updated); // Reload service config remoteNotificationService.reloadConfig(); const masked = maskSensitiveConfig(updated); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, config: masked })); } catch (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Invalid configuration', })); } return true; } // POST /api/notifications/remote/test if (pathname === '/api/notifications/remote/test' && req.method === 'POST') { const body = await readBody(req); try { const request = JSON.parse(body) as TestNotificationRequest; // Validate input const validation = validateTestRequest(request); if (!validation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: validation.error })); return true; } const result = await remoteNotificationService.testPlatform( request.platform as NotificationPlatform, request.config ); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Invalid request', })); } return true; } // POST /api/notifications/remote/reset if (pathname === '/api/notifications/remote/reset' && req.method === 'POST') { const config = resetConfig(); remoteNotificationService.reloadConfig(); const masked = maskSensitiveConfig(config); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, config: masked })); return true; } return false; } /** * Read request body as string */ async function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => resolve(body)); req.on('error', reject); }); }