Proteção XSS, assinatura de webhook, limite de execuções e data no histórico

- Utilitário centralizado Utils.escapeHtml() substituindo duplicações locais
- Escaping completo em todos os componentes (agents, tasks, schedules, pipelines, webhooks, terminal, history, tags)
- Verificação HMAC-SHA256 para webhooks usando raw body
- Limite de 5000 registros no store de execuções (maxSize)
- Data de execução visível no histórico com ícone de calendário
- Remoção de mutex desnecessário no flush síncrono do db.js
- Novos stores preparatórios (secrets, notifications, agentVersions)
This commit is contained in:
Frederico Castro
2026-02-26 18:26:27 -03:00
parent 93d9027e2c
commit d7d2421fc2
14 changed files with 135 additions and 126 deletions

View File

@@ -3437,50 +3437,36 @@ tbody tr:hover td {
white-space: nowrap;
}
.history-card-status {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.history-card-date {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.history-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.history-card-task {
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.history-card-info {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
.history-card-date,
.history-card-duration {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
gap: 5px;
white-space: nowrap;
}
.history-card-date i,
.history-card-duration i {
width: 12px;
height: 12px;
width: 13px;
height: 13px;
flex-shrink: 0;
opacity: 0.6;
}
.history-card-actions {
@@ -3986,12 +3972,6 @@ tbody tr:hover td {
height: 12px;
}
.history-card-duration-group {
display: flex;
align-items: center;
gap: 12px;
}
.pipeline-step-meta-group {
display: flex;
align-items: center;

View File

@@ -1118,6 +1118,7 @@
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<script src="js/api.js"></script>
<script src="js/utils.js"></script>
<script src="js/components/toast.js"></script>
<script src="js/components/modal.js"></script>
<script src="js/components/terminal.js"></script>

View File

@@ -253,7 +253,7 @@ const App = {
<div class="approval-icon"><i data-lucide="shield-alert"></i></div>
<div class="approval-text">
<strong>Aprovação necessária</strong>
<span>Passo ${stepIndex + 1} (${agentName || 'agente'}) aguardando autorização</span>
<span>Passo ${stepIndex + 1} (${Utils.escapeHtml(agentName) || 'agente'}) aguardando autorização</span>
</div>
<div class="approval-actions">
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
@@ -661,8 +661,8 @@ const App = {
hidden.value = JSON.stringify(tags);
chips.innerHTML = tags.map((t) => `
<span class="tag-chip">
${t}
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button>
${Utils.escapeHtml(t)}
<button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
</span>
`).join('');
};

View File

@@ -76,7 +76,7 @@ const AgentsUI = {
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
const tags = Array.isArray(agent.tags) && agent.tags.length > 0
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${t}</span>`).join('')}</div>`
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
: '';
return `
@@ -87,12 +87,12 @@ const AgentsUI = {
<span>${initials}</span>
</div>
<div class="agent-info">
<h3 class="agent-name">${name}</h3>
<h3 class="agent-name">${Utils.escapeHtml(name)}</h3>
<span class="badge ${statusClass}">${statusLabel}</span>
</div>
</div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
${tags}
<div class="agent-meta">
@@ -279,7 +279,7 @@ const AgentsUI = {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
allAgents
.filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join('');
selectEl.value = agentId;
@@ -311,7 +311,7 @@ const AgentsUI = {
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
tasks.map((t) => {
const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name;
return `<option value="${t.id}">${label}</option>`;
return `<option value="${t.id}">${Utils.escapeHtml(label)}</option>`;
}).join('');
AgentsUI._savedTasksCache = tasks;
} catch {

View File

@@ -78,8 +78,8 @@ const DashboardUI = {
list.innerHTML = executions.map((exec) => {
const statusClass = DashboardUI._statusBadgeClass(exec.status);
const statusLabel = DashboardUI._statusLabel(exec.status);
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução';
const taskText = exec.task || exec.input || '';
const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
const taskText = Utils.escapeHtml(exec.task || exec.input || '');
const typeBadge = exec.type === 'pipeline'
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
: '';

View File

@@ -70,22 +70,21 @@ const HistoryUI = {
<div class="history-card-header">
<div class="history-card-identity">
${typeBadge}
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span>
</div>
<div class="history-card-status">
<span class="history-card-name">${Utils.escapeHtml(name)}</span>
${statusBadge}
<span class="history-card-date">${date}</span>
</div>
</div>
<div class="history-card-meta">
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span>
<span class="history-card-duration-group">
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
</span>
${costHtml}
<div class="history-card-task">${Utils.escapeHtml(task)}</div>
<div class="history-card-info">
<span class="history-card-date">
<i data-lucide="calendar" aria-hidden="true"></i>
${date}
</span>
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
</span>
${costHtml}
</div>
<div class="history-card-actions">
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
@@ -195,18 +194,18 @@ const HistoryUI = {
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
const resultBlock = exec.result
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>`
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
: '';
const errorBlock = exec.error
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>`
? `<div class="execution-result execution-result--error" role="alert">${Utils.escapeHtml(exec.error)}</div>`
: '';
return `
<div class="execution-detail-meta">
<div class="execution-detail-row">
<span class="execution-detail-label">Agente</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span>
<span class="execution-detail-value">${Utils.escapeHtml(exec.agentName || exec.agentId || '—')}</span>
</div>
<div class="execution-detail-row">
<span class="execution-detail-label">Status</span>
@@ -243,7 +242,7 @@ const HistoryUI = {
${exec.task ? `
<div class="execution-detail-section">
<h3 class="execution-detail-section-title">Tarefa</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p>
<p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
</div>` : ''}
${resultBlock ? `
<div class="execution-detail-section">
@@ -279,7 +278,7 @@ const HistoryUI = {
<div class="pipeline-step-detail">
<div class="pipeline-step-header">
<div class="pipeline-step-identity">
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
<span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
${HistoryUI._statusBadge(step.status)}
</div>
<span class="pipeline-step-meta-group">
@@ -297,13 +296,13 @@ const HistoryUI = {
Prompt utilizado
</button>
<div class="pipeline-step-prompt-body" hidden>
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div>
<div class="execution-result execution-result--prompt">${Utils.escapeHtml(step.prompt)}</div>
</div>
</div>` : ''}
${step.result ? `
<div class="pipeline-step-result">
<span class="pipeline-step-result-label">Resultado</span>
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div>
<div class="execution-result">${Utils.escapeHtml(step.result)}</div>
</div>` : ''}
${step.status === 'error' ? `
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
@@ -316,7 +315,7 @@ const HistoryUI = {
<div class="execution-detail-meta">
<div class="execution-detail-row">
<span class="execution-detail-label">Pipeline</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
<span class="execution-detail-value">${Utils.escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
</div>
<div class="execution-detail-row">
<span class="execution-detail-label">Status</span>
@@ -343,7 +342,7 @@ const HistoryUI = {
${exec.input ? `
<div class="execution-detail-section">
<h3 class="execution-detail-section-title">Input Inicial</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p>
<p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
</div>` : ''}
${steps.length > 0 ? `
<div class="execution-detail-section">
@@ -355,7 +354,7 @@ const HistoryUI = {
${exec.error ? `
<div class="execution-detail-section">
<h3 class="execution-detail-section-title">Erro</h3>
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div>
<div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</div>
</div>` : ''}
`;
},
@@ -435,15 +434,6 @@ const HistoryUI = {
});
},
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
};
window.HistoryUI = HistoryUI;

View File

@@ -83,7 +83,7 @@ const PipelinesUI = {
const stepCount = steps.length;
const flowHtml = steps.map((step, index) => {
const agentName = step.agentName || step.agentId || 'Agente';
const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
const isLast = index === steps.length - 1;
const approvalIcon = step.requiresApproval && index > 0
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
@@ -102,12 +102,12 @@ const PipelinesUI = {
<div class="agent-card-body">
<div class="agent-card-top">
<div class="agent-info">
<h3 class="agent-name">${pipeline.name || 'Sem nome'}</h3>
<h3 class="agent-name">${Utils.escapeHtml(pipeline.name || 'Sem nome')}</h3>
<span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span>
</div>
</div>
${pipeline.description ? `<p class="agent-description">${pipeline.description}</p>` : ''}
${pipeline.description ? `<p class="agent-description">${Utils.escapeHtml(pipeline.description)}</p>` : ''}
<div class="pipeline-flow">
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
@@ -192,7 +192,7 @@ const PipelinesUI = {
}
const agentOptions = PipelinesUI.agents
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join('');
container.innerHTML = PipelinesUI._steps.map((step, index) => {
@@ -225,7 +225,7 @@ const PipelinesUI = {
placeholder="{{input}} será substituído pelo output anterior"
data-step-field="inputTemplate"
data-step-index="${index}"
>${step.inputTemplate || ''}</textarea>
>${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
${approvalHtml}
</div>
<div class="pipeline-step-actions">

View File

@@ -44,8 +44,8 @@ const SchedulesUI = {
return `
<tr>
<td>${schedule.agentName || '—'}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
<td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
<td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
<td>
<code class="font-mono">${cronExpr}</code>
</td>
@@ -106,7 +106,7 @@ const SchedulesUI = {
select.innerHTML = '<option value="">Selecionar agente...</option>' +
agents
.filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join('');
}
@@ -233,12 +233,12 @@ const SchedulesUI = {
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0;
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—';
const taskStr = SchedulesUI._escapeHtml(SchedulesUI._truncate(exec.task || '', 60));
const taskStr = Utils.escapeHtml(Utils.truncate(exec.task || '', 60));
return `
<tr>
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td>
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${Utils.escapeHtml(exec.agentName || '—')}</td>
<td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${status}</td>
<td>${date}</td>
<td>${duration}</td>
@@ -283,16 +283,6 @@ const SchedulesUI = {
return `${minutes}m ${seconds}s`;
},
_escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_truncate(str, max) {
if (!str || str.length <= max) return str;
return str.slice(0, max) + '…';
},
cronToHuman(expression) {
if (!expression) return '—';

View File

@@ -65,10 +65,10 @@ const TasksUI = {
return `
<div class="task-card" data-task-id="${task.id}">
<div class="task-card-header">
<h4 class="task-card-name">${task.name}</h4>
<span class="badge ${categoryClass}">${categoryLabel}</span>
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
</div>
${task.description ? `<p class="task-card-description">${task.description}</p>` : ''}
${task.description ? `<p class="task-card-description">${Utils.escapeHtml(task.description)}</p>` : ''}
<div class="task-card-footer">
<span class="task-card-date">
<i data-lucide="calendar"></i>
@@ -117,7 +117,7 @@ const TasksUI = {
<div class="task-card task-card--form" id="task-inline-form">
<div class="form-group">
<label class="form-label" for="task-inline-name">${title}</label>
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${task.name || ''}">
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${Utils.escapeHtml(task.name || '')}">
</div>
<div class="form-group">
<label class="form-label" for="task-inline-category">Categoria</label>
@@ -133,7 +133,7 @@ const TasksUI = {
</div>
<div class="form-group">
<label class="form-label" for="task-inline-description">Descrição</label>
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${task.description || ''}</textarea>
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${Utils.escapeHtml(task.description || '')}</textarea>
</div>
<div class="form-actions">
<button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
@@ -228,7 +228,7 @@ const TasksUI = {
const selectEl = document.getElementById('execute-agent-select');
if (selectEl) {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
activeAgents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join('');
activeAgents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
selectEl.value = '';
}

View File

@@ -102,7 +102,7 @@ const Terminal = {
const html = lines.map((line) => {
const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : '';
const escaped = Terminal._escapeHtml(line.content);
const escaped = Utils.escapeHtml(line.content);
const formatted = escaped.replace(/\n/g, '<br>');
return `<div class="terminal-line${typeClass}">
@@ -120,14 +120,6 @@ const Terminal = {
if (Terminal.autoScroll) Terminal.scrollToBottom();
},
_escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
};
window.Terminal = Terminal;

View File

@@ -71,7 +71,7 @@ const WebhooksUI = {
<article class="webhook-card">
<div class="webhook-card-header">
<div class="webhook-card-identity">
<span class="webhook-card-name">${WebhooksUI._escapeHtml(webhook.name)}</span>
<span class="webhook-card-name">${Utils.escapeHtml(webhook.name)}</span>
${typeBadge}
${statusBadge}
</div>
@@ -87,7 +87,7 @@ const WebhooksUI = {
<div class="webhook-card-body">
<div class="webhook-card-target">
<span class="webhook-card-label">Destino</span>
<span class="webhook-card-value">${WebhooksUI._escapeHtml(targetName)}</span>
<span class="webhook-card-value">${Utils.escapeHtml(targetName)}</span>
</div>
<div class="webhook-card-url">
<span class="webhook-card-label">URL</span>
@@ -150,10 +150,10 @@ const WebhooksUI = {
if (targetType === 'agent') {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
WebhooksUI.agents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join('');
WebhooksUI.agents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
} else {
selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' +
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${p.name}</option>`).join('');
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${Utils.escapeHtml(p.name)}</option>`).join('');
}
},
@@ -243,15 +243,6 @@ const WebhooksUI = {
}
},
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
};
window.WebhooksUI = WebhooksUI;

32
public/js/utils.js Normal file
View File

@@ -0,0 +1,32 @@
const Utils = {
escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
formatDuration(ms) {
if (!ms || ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m ${s}s`;
},
formatCost(usd) {
if (!usd || usd === 0) return '$0.0000';
return `$${Number(usd).toFixed(4)}`;
},
truncate(str, max = 80) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
},
};
window.Utils = Utils;