Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Comment on lines +63 to +69
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deepSanitize recreates BLOCKED_KEYS on every recursive call, which is unnecessary work (especially on large nested inputs). Move BLOCKED_KEYS to a module-level constant (or at least outside the function body) so recursion reuses the same set.

Suggested change
/**
* 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']);
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* 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) {

Copilot uses AI. Check for mistakes.
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);
}
Expand Down
22 changes: 19 additions & 3 deletions src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Comment on lines +174 to 179
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanMessage may start with a space after sanitization (e.g. when the raw message begins with \n/\r and those are replaced with spaces). That can bypass the ignore_messages.some(m => cleanMessage.startsWith(m)) filter. Consider trimming (or at least trimStart()) the sanitized message before startsWith checks and downstream processing.

Copilot uses AI. Check for mistakes.
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) {
Expand Down
99 changes: 99 additions & 0 deletions src/utils/message_validator.js
Original file line number Diff line number Diff line change
@@ -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` };
}
Comment on lines +22 to +27
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateDiscordMessage checks !message before validating typeof message === 'string', so non-string falsy values (e.g. 0) will be reported as "Empty message" instead of a type error. Consider checking the type first, then treating empty/whitespace-only strings as empty after trimming.

Copilot uses AI. Check for mistakes.

// 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` };
}
Comment on lines +64 to +69
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateMinecraftMessage checks !message before checking typeof message === 'string', so non-string falsy inputs will be misclassified as "Empty message". Reorder the validations (type check first), and consider trimming/collapsing whitespace after replacing newlines to avoid producing leading spaces.

Copilot uses AI. Check for mistakes.

// 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)) {
Comment on lines +93 to +95
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateUsername checks !username before validating the input type, so a non-string falsy value would return "Username is empty" rather than "Username must be a string". Reorder validations to check typeof username first, then validate emptiness/format on the trimmed string.

Suggested change
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)) {
if (typeof username !== 'string') return { valid: false, error: 'Username must be a string' };
const trimmedUsername = username.trim();
if (!trimmedUsername) return { valid: false, error: 'Username is empty' };
if (!/^[a-zA-Z0-9_]{3,16}$/.test(trimmedUsername)) {

Copilot uses AI. Check for mistakes.
return { valid: false, error: 'Username must be 3-16 alphanumeric chars or underscore' };
}
return { valid: true };
}
100 changes: 100 additions & 0 deletions src/utils/rate_limiter.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
Comment on lines +1 to +18
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RateLimiter is introduced but not referenced anywhere in src/ (no callers found), so this PR doesn't actually apply rate limiting as described in the PR summary/title. Either integrate it at the appropriate ingress (e.g., message receive path) or adjust the PR description/title to reflect the current scope.

Copilot uses AI. Check for mistakes.

/**
* 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;
}
}
}
Loading