Skip to content
Open
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
49 changes: 37 additions & 12 deletions src/utils/keys.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,49 @@
import { readFileSync } from 'fs';
import { deepSanitize } from '../../settings.js';

Comment on lines +2 to 3
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

deepSanitize is imported from ../../settings.js, but settings.js only exports a default settings object and does not provide a named deepSanitize export. This will throw at module load time and break every model that imports getKey. Define/export deepSanitize (or import it from the correct module) before using it here.

Suggested change
import { deepSanitize } from '../../settings.js';
// Local deepSanitize implementation to avoid relying on a non-existent named export.
// Currently acts as a no-op passthrough; adjust if stricter sanitization is needed.
function deepSanitize(value) {
return value;
}

Copilot uses AI. Check for mistakes.
let keys = {};
try {
const data = readFileSync('./keys.json', 'utf8');
keys = JSON.parse(data);
} catch (err) {
console.warn('keys.json not found. Defaulting to environment variables.'); // still works with local models
let keysLoaded = false;
const _warnedKeys = new Set();

// Try to load keys.json only if it exists, but prefer environment variables
function loadKeysFile() {
if (keysLoaded) return;
try {
const data = readFileSync('./keys.json', 'utf8');
keys = deepSanitize(JSON.parse(data));
console.warn('⚠️ WARNING: keys.json loaded into memory. Use environment variables instead for better security.');
} catch (_err) {
// keys.json not found or unreadable — that's fine, use env vars
Comment on lines +15 to +16
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

loadKeysFile() catches and ignores all errors (including JSON parse errors or permission issues) and then sets keysLoaded = true. This can mask a malformed/unreadable keys.json and later surface as a misleading "key not found" error. Consider only ignoring ENOENT (missing file) and logging/rethrowing for other error types (including SyntaxError from JSON.parse).

Suggested change
} catch (_err) {
// keys.json not found or unreadable — that's fine, use env vars
} catch (err) {
// Only ignore missing file; other errors should be surfaced
if (!err || err.code !== 'ENOENT') {
console.error('Failed to load keys.json:', err);
throw err;
}

Copilot uses AI. Check for mistakes.
}
keysLoaded = true;
}

export function getKey(name) {
let key = keys[name];
if (!key) {
key = process.env[name];
// Priority 1: Environment variables (most secure)
let key = process.env[name];
if (key) {
return key;
}
if (!key) {
throw new Error(`API key "${name}" not found in keys.json or environment variables!`);

// Priority 2: keys.json fallback (legacy, less secure)
loadKeysFile();
key = keys[name];
if (key) {
if (!_warnedKeys.has(name)) {
_warnedKeys.add(name);
console.warn(`\u26A0\uFE0F Using key from keys.json for "${name}". Migrate to environment variables.`);
}
return key;
}
return key;

throw new Error(`API key "${name}" not found in environment variables or keys.json. Set ${name} as an environment variable.`);
}

export function hasKey(name) {
return keys[name] || process.env[name];
// Check env vars first
if (process.env[name]) return true;

// Check keys.json as fallback
loadKeysFile();
return !!keys[name];
}
97 changes: 97 additions & 0 deletions src/utils/message_validator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* 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) {
Comment on lines +22 to +25
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The !message check runs before the typeof message !== 'string' check, so non-string falsy values (e.g. 0, false) will return the misleading error "Empty message" instead of "Message must be a string". Swap the order (type-check first) or explicitly check for empty string after validating the type.

Copilot uses AI. Check for mistakes.
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 = [
/[\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) {
Comment on lines +63 to +66
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

Same issue as validateDiscordMessage: !message runs before the type check, so non-string falsy values produce "Empty message" rather than a type error. Type-check first, then validate emptiness.

Copilot uses AI. Check for mistakes.
return { valid: false, error: `Message exceeds ${MAX_MINECRAFT_MESSAGE_LENGTH} characters` };
}

// Minecraft chat allows most characters; filter only dangerous ones
const dangerousPatterns = [
/[\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' };
Comment on lines +91 to +92
Copy link

Copilot AI Mar 4, 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 type, so non-string falsy values return "Username is empty" rather than "Username must be a string". Validate typeof username === 'string' first, then check for empty/length constraints.

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 (typeof username !== 'string') return { valid: false, error: 'Username must be a string' };
if (username.length === 0) return { valid: false, error: 'Username is empty' };

Copilot uses AI. Check for mistakes.
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 };
}
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();
}
}

/**
* 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