diff --git a/settings.js b/settings.js index d7450cf8d..3e4705dc2 100644 --- a/settings.js +++ b/settings.js @@ -60,9 +60,30 @@ const settings = { } +/** + * Recursively strips prototype-polluting keys from an object. + * Prevents __proto__, constructor, and prototype injection at any depth + * when parsing untrusted JSON (e.g. SETTINGS_JSON env var). + */ +function deepSanitize(obj) { + const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(item => deepSanitize(item)); + const sanitized = {}; + for (const [key, value] of Object.entries(obj)) { + if (BLOCKED_KEYS.has(key)) continue; + sanitized[key] = (typeof value === 'object' && value !== null) + ? deepSanitize(value) + : value; + } + return sanitized; +} + if (process.env.SETTINGS_JSON) { try { - Object.assign(settings, JSON.parse(process.env.SETTINGS_JSON)); + const parsed = JSON.parse(process.env.SETTINGS_JSON); + const safe = deepSanitize(parsed); + Object.assign(settings, safe); } catch (err) { console.error("Failed to parse SETTINGS_JSON:", err); } diff --git a/src/agent/agent.js b/src/agent/agent.js index f5a8e3d52..09d77c8c6 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -17,6 +17,7 @@ import settings from './settings.js'; import { Task } from './tasks/tasks.js'; import { speak } from './speak.js'; import { log, validateNameFormat, handleDisconnection } from './connection_handler.js'; +import { validateMinecraftMessage, validateUsername } from '../utils/message_validator.js'; export class Agent { async start(load_mem=false, init_message=null, count_id=0) { @@ -157,19 +158,34 @@ export class Agent { const respondFunc = async (username, message) => { if (message === "") return; if (username === this.name) return; + + // Validate username and message before processing + const userValidation = validateUsername(username); + if (!userValidation.valid) { + console.warn(`[MessageValidator] Rejected message from invalid username: "${username}" (${userValidation.error})`); + return; + } + + const msgValidation = validateMinecraftMessage(message); + if (!msgValidation.valid) { + console.warn(`[MessageValidator] Rejected message: ${msgValidation.error}`); + return; + } + const cleanMessage = msgValidation.sanitized; + if (settings.only_chat_with.length > 0 && !settings.only_chat_with.includes(username)) return; try { - if (ignore_messages.some((m) => message.startsWith(m))) return; + if (ignore_messages.some((m) => cleanMessage.startsWith(m))) return; this.shut_up = false; - console.log(this.name, 'received message from', username, ':', message); + console.log(this.name, 'received message from', username, ':', cleanMessage); if (convoManager.isOtherAgent(username)) { console.warn('received whisper from other bot??') } else { - let translation = await handleEnglishTranslation(message); + let translation = await handleEnglishTranslation(cleanMessage); this.handleMessage(username, translation); } } catch (error) { diff --git a/src/utils/message_validator.js b/src/utils/message_validator.js new file mode 100644 index 000000000..fb0cdc9fd --- /dev/null +++ b/src/utils/message_validator.js @@ -0,0 +1,99 @@ +/** + * Validates and sanitizes user messages for safety. + * Prevents protocol exploits, injection attacks, and abuse. + */ + +const MAX_DISCORD_MESSAGE_LENGTH = 512; +const MAX_MINECRAFT_MESSAGE_LENGTH = 256; + +// Patterns that may indicate command injection or abuse +const COMMAND_INJECTION_PATTERNS = [ + /;\s*(rm|del|format|shutdown|reboot|kill|wget|curl)\b/i, + /\$\(.*\)/, // Shell command substitution + /`[^`]*`/, // Backtick command execution + /\|\s*(bash|sh|cmd|powershell)/i, // Pipe to shell +]; + +/** + * Validates a Discord message + * @param {string} message - Raw message from Discord user + * @returns {object} { valid: boolean, error?: string, sanitized?: string, warnings?: string[] } + */ +export function validateDiscordMessage(message) { + if (!message) return { valid: false, error: 'Empty message' }; + if (typeof message !== 'string') return { valid: false, error: 'Message must be a string' }; + if (message.length > MAX_DISCORD_MESSAGE_LENGTH) { + return { valid: false, error: `Message exceeds ${MAX_DISCORD_MESSAGE_LENGTH} characters` }; + } + + // Block command injection patterns outright — do not pass these to agents. + for (const pattern of COMMAND_INJECTION_PATTERNS) { + if (pattern.test(message)) { + console.warn(`[MessageValidator] Blocked message containing disallowed pattern: ${pattern.source}`); + return { valid: false, error: `Message contains a disallowed pattern and was blocked.` }; + } + } + + const warnings = []; + + // Alert on suspicious patterns (but allow them for agents to handle) + const suspiciousPatterns = [ + // eslint-disable-next-line no-control-regex + /[\x00-\x08\x0B-\x0C\x0E-\x1F]/g, // Control characters + /[\uFEFF]/g, // BOM + ]; + + let sanitized = message; + for (const pattern of suspiciousPatterns) { + sanitized = sanitized.replace(pattern, ''); + } + + if (sanitized !== message) { + warnings.push('Removed control characters from message'); + console.warn('[MessageValidator] Removed control characters from Discord message'); + } + + return { valid: true, sanitized, warnings: warnings.length > 0 ? warnings : undefined }; +} + +/** + * Validates a Minecraft in-game message + * @param {string} message - Raw message from Minecraft chat + * @returns {object} { valid: boolean, error?: string, sanitized?: string } + */ +export function validateMinecraftMessage(message) { + if (!message) return { valid: false, error: 'Empty message' }; + if (typeof message !== 'string') return { valid: false, error: 'Message must be a string' }; + if (message.length > MAX_MINECRAFT_MESSAGE_LENGTH) { + return { valid: false, error: `Message exceeds ${MAX_MINECRAFT_MESSAGE_LENGTH} characters` }; + } + + // Minecraft chat allows most characters; filter only dangerous ones + const dangerousPatterns = [ + // eslint-disable-next-line no-control-regex + /[\x00]/g, // Null byte (protocol exploit) + /\n/g, // Newlines (message splitting) + /\r/g, // Carriage return + ]; + + let sanitized = message; + for (const pattern of dangerousPatterns) { + sanitized = sanitized.replace(pattern, ' '); + } + + return { valid: true, sanitized }; +} + +/** + * Validates a username (bot or player name) + * @param {string} username - Username to validate + * @returns {object} { valid: boolean, error?: string } + */ +export function validateUsername(username) { + if (!username) return { valid: false, error: 'Username is empty' }; + if (typeof username !== 'string') return { valid: false, error: 'Username must be a string' }; + if (!/^[a-zA-Z0-9_]{3,16}$/.test(username)) { + return { valid: false, error: 'Username must be 3-16 alphanumeric chars or underscore' }; + } + return { valid: true }; +} diff --git a/src/utils/rate_limiter.js b/src/utils/rate_limiter.js new file mode 100644 index 000000000..ba8752d5c --- /dev/null +++ b/src/utils/rate_limiter.js @@ -0,0 +1,100 @@ +/** + * Simple in-memory rate limiter to prevent abuse. + * Includes automatic stale entry cleanup to prevent memory leaks. + */ + +export class RateLimiter { + constructor(maxRequests = 5, windowMs = 60000, cleanupIntervalMs = 300000) { + this.maxRequests = maxRequests; // Max requests per window + this.windowMs = windowMs; // Time window in milliseconds + this.requests = new Map(); // userId → [timestamps] + + // Periodically purge stale entries to prevent unbounded memory growth + this._cleanupInterval = setInterval(() => this._purgeStale(), cleanupIntervalMs); + // Allow the process to exit even if the interval is still running + if (this._cleanupInterval.unref) { + this._cleanupInterval.unref(); + } + } + + /** + * Check if a user has exceeded rate limit + * @param {string} userId - Discord user ID + * @returns {object} { allowed: boolean, retryAfterSeconds?: number } + */ + checkLimit(userId) { + const now = Date.now(); + const userRequests = this.requests.get(userId) || []; + + // Remove old requests outside the window + const recentRequests = userRequests.filter(ts => now - ts < this.windowMs); + + if (recentRequests.length >= this.maxRequests) { + // Rate limited + const oldestRequest = recentRequests[0]; + const retryAfterMs = oldestRequest + this.windowMs - now; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + return { allowed: false, retryAfterSeconds }; + } + + // Allow and record + recentRequests.push(now); + this.requests.set(userId, recentRequests); + return { allowed: true }; + } + + /** + * Reset limits for a user (useful for admins) + */ + reset(userId) { + this.requests.delete(userId); + } + + /** + * Reset all tracked users + */ + resetAll() { + this.requests.clear(); + } + + /** + * Get current stats for a user + */ + getStats(userId) { + const now = Date.now(); + const requests = this.requests.get(userId) || []; + const recentRequests = requests.filter(ts => now - ts < this.windowMs); + return { + current: recentRequests.length, + max: this.maxRequests, + windowSeconds: this.windowMs / 1000, + trackedUsers: this.requests.size, + }; + } + + /** + * Remove entries for users with no recent requests (prevents memory leak) + * @private + */ + _purgeStale() { + const now = Date.now(); + for (const [userId, timestamps] of this.requests) { + const recent = timestamps.filter(ts => now - ts < this.windowMs); + if (recent.length === 0) { + this.requests.delete(userId); + } else { + this.requests.set(userId, recent); + } + } + } + + /** + * Stop the automatic cleanup interval (for graceful shutdown) + */ + destroy() { + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + this._cleanupInterval = null; + } + } +}