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:
2026-03-11 22:53:25 -04:00
parent 0fee118d1d
commit 58c172e131
5 changed files with 169 additions and 394 deletions

View File

@@ -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) {