Security hardening, bug fixes, and performance improvements
Security fixes: - Replace new Function() condition eval with vm.runInNewContext() (RCE fix) - Add admin checks to DELETE executions, all scheduled-commands endpoints - Remove api_key from GET /api/workers response (was exposed to all employees) - Separate browserClients/workerClients sets; broadcast() now sends to browsers only - Add worker WebSocket auth: reject if api_key provided but invalid - Fix XSS: escapeHtml() on step_name, duration, worker_id, user info, execution_id Bug fixes: - Replace DB-polling waitForCommandResult with event-driven _commandResolvers Map - Replace non-atomic addExecutionLog with JSON_ARRAY_APPEND (fixes concurrent write race) - Add stale execution recovery on startup: running→failed with log entry - Fix calculateNextRun returning null for unknown types (now throws) - Fix scheduler overlap: skip if previous execution still running - Fix JSON double-parse on worker_ids column - Fix switchTab() bare event.target reference - Fix selectedExecutions Array→Set (O(1) lookups, fixes performance regression) - Fix param modal event listener leak (delegated handler, removes before re-adding) - Add ws.onerror handler (was silently swallowing WebSocket errors) - Move misplaced routes to before server.listen() Performance/cleanup: - DB connection pool 10→50 - EXECUTION_RETENTION_DAYS default 1→30 (matches docs) - Remove unused packages: bcryptjs, body-parser, cors, js-yaml, jsonwebtoken - Remove generateUUID() wrapper, use crypto.randomUUID() directly - Remove dead example workflow constants - Add ESC key handler to close modals - Fix clearCompletedExecutions limit 1000→9999 - Add security notice to README.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1095,7 +1095,7 @@
|
||||
let workers = [];
|
||||
let allExecutions = []; // Store all loaded executions for filtering
|
||||
let compareMode = false;
|
||||
let selectedExecutions = [];
|
||||
let selectedExecutions = new Set();
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
@@ -1104,10 +1104,10 @@
|
||||
|
||||
currentUser = await response.json();
|
||||
document.getElementById('userInfo').innerHTML = `
|
||||
<div class="name">${currentUser.name}</div>
|
||||
<div class="email">${currentUser.email}</div>
|
||||
<div>${currentUser.groups.map(g =>
|
||||
`<span class="badge">${g}</span>`
|
||||
<div class="name">${escapeHtml(currentUser.name || '')}</div>
|
||||
<div class="email">${escapeHtml(currentUser.email || '')}</div>
|
||||
<div>${(currentUser.groups || []).map(g =>
|
||||
`<span class="badge">${escapeHtml(g)}</span>`
|
||||
).join('')}</div>
|
||||
`;
|
||||
return true;
|
||||
@@ -1593,7 +1593,7 @@
|
||||
const fullHtml = filtered.length === 0 ?
|
||||
'<div class="empty">No executions match your filters</div>' :
|
||||
filtered.map(e => {
|
||||
const isSelected = selectedExecutions.includes(e.id);
|
||||
const isSelected = selectedExecutions.has(e.id);
|
||||
const clickHandler = compareMode ? `toggleExecutionSelection('${e.id}')` : `viewExecution('${e.id}')`;
|
||||
const selectedStyle = isSelected ? 'background: rgba(255, 176, 0, 0.2); border-left-width: 5px; border-left-color: var(--terminal-amber);' : '';
|
||||
|
||||
@@ -1625,7 +1625,7 @@
|
||||
|
||||
function toggleCompareMode() {
|
||||
compareMode = !compareMode;
|
||||
selectedExecutions = [];
|
||||
selectedExecutions = new Set();
|
||||
|
||||
const btn = document.getElementById('compareModeBtn');
|
||||
const compareBtn = document.getElementById('compareBtn');
|
||||
@@ -1649,31 +1649,29 @@
|
||||
}
|
||||
|
||||
function toggleExecutionSelection(executionId) {
|
||||
const index = selectedExecutions.indexOf(executionId);
|
||||
|
||||
if (index > -1) {
|
||||
selectedExecutions.splice(index, 1);
|
||||
if (selectedExecutions.has(executionId)) {
|
||||
selectedExecutions.delete(executionId);
|
||||
} else {
|
||||
if (selectedExecutions.length >= 5) {
|
||||
if (selectedExecutions.size >= 5) {
|
||||
showTerminalNotification('Maximum 5 executions can be compared', 'error');
|
||||
return;
|
||||
}
|
||||
selectedExecutions.push(executionId);
|
||||
selectedExecutions.add(executionId);
|
||||
}
|
||||
|
||||
renderFilteredExecutions();
|
||||
|
||||
// Update compare button text
|
||||
const compareBtn = document.getElementById('compareBtn');
|
||||
if (selectedExecutions.length >= 2) {
|
||||
compareBtn.textContent = `[ ⚖️ Compare Selected (${selectedExecutions.length}) ]`;
|
||||
if (selectedExecutions.size >= 2) {
|
||||
compareBtn.textContent = `[ ⚖️ Compare Selected (${selectedExecutions.size}) ]`;
|
||||
} else {
|
||||
compareBtn.textContent = '[ ⚖️ Compare Selected ]';
|
||||
}
|
||||
}
|
||||
|
||||
async function compareSelectedExecutions() {
|
||||
if (selectedExecutions.length < 2) {
|
||||
if (selectedExecutions.size < 2) {
|
||||
showTerminalNotification('Please select at least 2 executions to compare', 'error');
|
||||
return;
|
||||
}
|
||||
@@ -1837,7 +1835,7 @@
|
||||
if (!confirm('Delete all completed and failed executions?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/executions?limit=1000'); // Get all executions
|
||||
const response = await fetch('/api/executions?limit=9999'); // Get all executions
|
||||
const data = await response.json();
|
||||
const executions = data.executions || data; // Handle new pagination format
|
||||
|
||||
@@ -1911,10 +1909,10 @@
|
||||
${p.required ? 'required' : ''}>
|
||||
</div>`).join('');
|
||||
document.getElementById('paramModal').style.display = 'flex';
|
||||
// Focus first input; Enter key submits
|
||||
form.querySelectorAll('input').forEach(inp => {
|
||||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') submitParamForm(); });
|
||||
});
|
||||
// Focus first input; Enter key submits — use single delegated listener to avoid duplicates
|
||||
if (form._keydownHandler) form.removeEventListener('keydown', form._keydownHandler);
|
||||
form._keydownHandler = (e) => { if (e.key === 'Enter' && e.target.tagName === 'INPUT') submitParamForm(); };
|
||||
form.addEventListener('keydown', form._keydownHandler);
|
||||
const first = form.querySelector('input');
|
||||
if (first) setTimeout(() => first.focus(), 50);
|
||||
}
|
||||
@@ -2052,7 +2050,7 @@
|
||||
return `
|
||||
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
|
||||
<div class="log-timestamp">[${timestamp}]</div>
|
||||
<div class="log-title" style="color: var(--terminal-amber);">▶️ Step ${log.step}: ${log.step_name}</div>
|
||||
<div class="log-title" style="color: var(--terminal-amber);">▶️ Step ${log.step}: ${escapeHtml(log.step_name || '')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2061,7 +2059,7 @@
|
||||
return `
|
||||
<div class="log-entry" style="border-left-color: var(--terminal-green);">
|
||||
<div class="log-timestamp">[${timestamp}]</div>
|
||||
<div class="log-title" style="color: var(--terminal-green);">✓ Step ${log.step} Completed: ${log.step_name}</div>
|
||||
<div class="log-title" style="color: var(--terminal-green);">✓ Step ${log.step} Completed: ${escapeHtml(log.step_name || '')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2070,7 +2068,7 @@
|
||||
return `
|
||||
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
|
||||
<div class="log-timestamp">[${timestamp}]</div>
|
||||
<div class="log-title" style="color: var(--terminal-amber);">⏳ Waiting ${log.duration} seconds...</div>
|
||||
<div class="log-title" style="color: var(--terminal-amber);">⏳ Waiting ${escapeHtml(String(log.duration || 0))} seconds...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2093,7 +2091,7 @@
|
||||
<div class="log-timestamp">[${timestamp}]</div>
|
||||
<div class="log-title" style="color: #ff4444;">⚠️ Worker Offline</div>
|
||||
<div class="log-details">
|
||||
<div class="log-field"><span class="log-label">Worker ID:</span> ${log.worker_id}</div>
|
||||
<div class="log-field"><span class="log-label">Worker ID:</span> ${escapeHtml(log.worker_id || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2583,7 +2581,7 @@
|
||||
<div style="background: #001a00; border: 2px solid var(--terminal-green); padding: 15px;">
|
||||
<strong style="color: var(--terminal-green);">✓ Command sent successfully!</strong>
|
||||
<div style="margin-top: 10px; font-family: var(--font-mono); font-size: 0.9em; color: var(--terminal-green);">
|
||||
Execution ID: ${data.execution_id}
|
||||
Execution ID: ${escapeHtml(String(data.execution_id || ''))}
|
||||
</div>
|
||||
<div style="margin-top: 10px; color: var(--terminal-amber);">
|
||||
Check the Executions tab to see the results
|
||||
@@ -2697,9 +2695,12 @@
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
|
||||
// Find the button by its onclick attribute rather than relying on bare `event`
|
||||
const tabBtn = document.querySelector(`.tab[onclick*="'${tabName}'"]`);
|
||||
if (tabBtn) tabBtn.classList.add('active');
|
||||
const tabContent = document.getElementById(tabName);
|
||||
if (tabContent) tabContent.classList.add('active');
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
@@ -2901,8 +2902,23 @@
|
||||
console.log('WebSocket closed, reconnecting...');
|
||||
setTimeout(connectWebSocket, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] Connection error:', error);
|
||||
};
|
||||
}
|
||||
|
||||
// Close any open modal on ESC key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
if (modal.style.display && modal.style.display !== 'none') {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
loadUser().then((success) => {
|
||||
if (success) {
|
||||
|
||||
Reference in New Issue
Block a user