feat(security): implement AES-256-GCM encryption for API keys and HMAC-signed session tokens
This commit introduces: - AES-256-GCM encryption for LLM provider API keys in the database. - HMAC-SHA256 signed session tokens with activity-based refresh logic. - Standardized frontend XSS protection using a global escapeHtml utility. - Hardened security headers and request body size limits. - Improved database integrity with foreign key enforcement and atomic transactions. - Integration tests for the full encrypted key storage and proxy usage lifecycle.
This commit is contained in:
@@ -35,6 +35,14 @@ class ApiClient {
|
||||
throw new Error(result.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handling X-Refreshed-Token header
|
||||
if (response.headers.get('X-Refreshed-Token') && window.authManager) {
|
||||
window.authManager.token = response.headers.get('X-Refreshed-Token');
|
||||
if (window.authManager.setToken) {
|
||||
window.authManager.setToken(window.authManager.token);
|
||||
}
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -87,6 +95,17 @@ class ApiClient {
|
||||
const date = luxon.DateTime.fromISO(dateStr);
|
||||
return date.toRelative();
|
||||
}
|
||||
|
||||
// Helper for escaping HTML
|
||||
escapeHtml(unsafe) {
|
||||
if (unsafe === undefined || unsafe === null) return '';
|
||||
return unsafe.toString()
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
}
|
||||
|
||||
window.api = new ApiClient();
|
||||
|
||||
@@ -50,6 +50,12 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
setToken(newToken) {
|
||||
if (!newToken) return;
|
||||
this.token = newToken;
|
||||
localStorage.setItem('dashboard_token', this.token);
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
const errorElement = document.getElementById('login-error');
|
||||
const loginBtn = document.querySelector('.login-btn');
|
||||
|
||||
@@ -42,12 +42,15 @@ class ClientsPage {
|
||||
const statusIcon = client.status === 'active' ? 'check-circle' : 'clock';
|
||||
const created = luxon.DateTime.fromISO(client.created_at).toFormat('MMM dd, yyyy');
|
||||
|
||||
const escapedId = window.api.escapeHtml(client.id);
|
||||
const escapedName = window.api.escapeHtml(client.name);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><span class="badge-client">${client.id}</span></td>
|
||||
<td><strong>${client.name}</strong></td>
|
||||
<td><span class="badge-client">${escapedId}</span></td>
|
||||
<td><strong>${escapedName}</strong></td>
|
||||
<td>
|
||||
<code class="token-display">sk-••••${client.id.substring(client.id.length - 4)}</code>
|
||||
<code class="token-display">sk-••••${escapedId.substring(escapedId.length - 4)}</code>
|
||||
</td>
|
||||
<td>${created}</td>
|
||||
<td>${client.last_used ? window.api.formatTimeAgo(client.last_used) : 'Never'}</td>
|
||||
@@ -55,16 +58,16 @@ class ClientsPage {
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">
|
||||
<i class="fas fa-${statusIcon}"></i>
|
||||
${client.status}
|
||||
${window.api.escapeHtml(client.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${window._userRole === 'admin' ? `
|
||||
<div class="action-buttons">
|
||||
<button class="btn-action" title="Edit" onclick="window.clientsPage.editClient('${client.id}')">
|
||||
<button class="btn-action" title="Edit" onclick="window.clientsPage.editClient('${escapedId}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn-action danger" title="Delete" onclick="window.clientsPage.deleteClient('${client.id}')">
|
||||
<button class="btn-action danger" title="Delete" onclick="window.clientsPage.deleteClient('${escapedId}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -188,10 +191,13 @@ class ClientsPage {
|
||||
showTokenRevealModal(clientName, token) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
const escapedName = window.api.escapeHtml(clientName);
|
||||
const escapedToken = window.api.escapeHtml(token);
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Client Created: ${clientName}</h3>
|
||||
<h3 class="modal-title">Client Created: ${escapedName}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 0.75rem; color: var(--yellow);">
|
||||
@@ -201,7 +207,7 @@ class ClientsPage {
|
||||
<div class="form-control">
|
||||
<label>API Token</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" id="revealed-token" value="${token}" readonly
|
||||
<input type="text" id="revealed-token" value="${escapedToken}" readonly
|
||||
style="font-family: monospace; font-size: 0.85rem;">
|
||||
<button class="btn btn-secondary" id="copy-token-btn" title="Copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
@@ -248,10 +254,16 @@ class ClientsPage {
|
||||
showEditClientModal(client) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
|
||||
const escapedId = window.api.escapeHtml(client.id);
|
||||
const escapedName = window.api.escapeHtml(client.name);
|
||||
const escapedDescription = window.api.escapeHtml(client.description);
|
||||
const escapedRateLimit = window.api.escapeHtml(client.rate_limit_per_minute);
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Edit Client: ${client.id}</h3>
|
||||
<h3 class="modal-title">Edit Client: ${escapedId}</h3>
|
||||
<button class="modal-close" onclick="this.closest('.modal').remove()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -259,15 +271,15 @@ class ClientsPage {
|
||||
<div class="modal-body">
|
||||
<div class="form-control">
|
||||
<label for="edit-client-name">Display Name</label>
|
||||
<input type="text" id="edit-client-name" value="${client.name || ''}" placeholder="e.g. My Coding Assistant">
|
||||
<input type="text" id="edit-client-name" value="${escapedName}" placeholder="e.g. My Coding Assistant">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="edit-client-description">Description</label>
|
||||
<textarea id="edit-client-description" rows="3" placeholder="Optional description">${client.description || ''}</textarea>
|
||||
<textarea id="edit-client-description" rows="3" placeholder="Optional description">${escapedDescription}</textarea>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="edit-client-rate-limit">Rate Limit (requests/minute)</label>
|
||||
<input type="number" id="edit-client-rate-limit" min="0" value="${client.rate_limit_per_minute || ''}" placeholder="Leave empty for unlimited">
|
||||
<input type="number" id="edit-client-rate-limit" min="0" value="${escapedRateLimit}" placeholder="Leave empty for unlimited">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="toggle-label">
|
||||
@@ -357,12 +369,16 @@ class ClientsPage {
|
||||
const lastUsed = t.last_used_at
|
||||
? luxon.DateTime.fromISO(t.last_used_at).toRelative()
|
||||
: 'Never';
|
||||
const escapedMaskedToken = window.api.escapeHtml(t.token_masked);
|
||||
const escapedClientId = window.api.escapeHtml(clientId);
|
||||
const tokenId = parseInt(t.id); // Assuming ID is numeric
|
||||
|
||||
return `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--bg3);">
|
||||
<code style="flex: 1; font-size: 0.8rem; color: var(--fg2);">${t.token_masked}</code>
|
||||
<code style="flex: 1; font-size: 0.8rem; color: var(--fg2);">${escapedMaskedToken}</code>
|
||||
<span style="font-size: 0.75rem; color: var(--fg4);" title="Last used">${lastUsed}</span>
|
||||
<button class="btn-action danger" title="Revoke" style="padding: 0.2rem 0.4rem;"
|
||||
onclick="window.clientsPage.revokeToken('${clientId}', ${t.id}, this)">
|
||||
onclick="window.clientsPage.revokeToken('${escapedClientId}', ${tokenId}, this)">
|
||||
<i class="fas fa-trash" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -47,16 +47,21 @@ class ProvidersPage {
|
||||
const isLowBalance = provider.credit_balance <= provider.low_credit_threshold && provider.id !== 'ollama';
|
||||
const balanceColor = isLowBalance ? 'var(--red-light)' : 'var(--green-light)';
|
||||
|
||||
const escapedId = window.api.escapeHtml(provider.id);
|
||||
const escapedName = window.api.escapeHtml(provider.name);
|
||||
const escapedStatus = window.api.escapeHtml(provider.status);
|
||||
const billingMode = provider.billing_mode ? provider.billing_mode.toUpperCase() : 'PREPAID';
|
||||
|
||||
return `
|
||||
<div class="provider-card ${provider.status}">
|
||||
<div class="provider-card ${escapedStatus}">
|
||||
<div class="provider-card-header">
|
||||
<div class="provider-info">
|
||||
<h4 class="provider-name">${provider.name}</h4>
|
||||
<span class="provider-id">${provider.id}</span>
|
||||
<h4 class="provider-name">${escapedName}</h4>
|
||||
<span class="provider-id">${escapedId}</span>
|
||||
</div>
|
||||
<span class="status-badge ${statusClass}">
|
||||
<i class="fas fa-circle"></i>
|
||||
${provider.status}
|
||||
${escapedStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div class="provider-card-body">
|
||||
@@ -67,12 +72,12 @@ class ProvidersPage {
|
||||
</div>
|
||||
<div class="meta-item" style="color: ${balanceColor}; font-weight: 700;">
|
||||
<i class="fas fa-wallet"></i>
|
||||
<span>Balance: ${provider.id === 'ollama' ? 'FREE' : window.api.formatCurrency(provider.credit_balance)}</span>
|
||||
<span>Balance: ${escapedId === 'ollama' ? 'FREE' : window.api.formatCurrency(provider.credit_balance)}</span>
|
||||
${isLowBalance ? '<i class="fas fa-exclamation-triangle" title="Low Balance"></i>' : ''}
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<span>Billing: ${provider.billing_mode ? provider.billing_mode.toUpperCase() : 'PREPAID'}</span>
|
||||
<span>Billing: ${window.api.escapeHtml(billingMode)}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<i class="fas fa-clock"></i>
|
||||
@@ -80,16 +85,16 @@ class ProvidersPage {
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-tags">
|
||||
${(provider.models || []).slice(0, 5).map(m => `<span class="model-tag">${m}</span>`).join('')}
|
||||
${(provider.models || []).slice(0, 5).map(m => `<span class="model-tag">${window.api.escapeHtml(m)}</span>`).join('')}
|
||||
${modelCount > 5 ? `<span class="model-tag more">+${modelCount - 5} more</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-footer">
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.providersPage.testProvider('${provider.id}')">
|
||||
<button class="btn btn-secondary btn-sm" onclick="window.providersPage.testProvider('${escapedId}')">
|
||||
<i class="fas fa-vial"></i> Test
|
||||
</button>
|
||||
${window._userRole === 'admin' ? `
|
||||
<button class="btn btn-primary btn-sm" onclick="window.providersPage.configureProvider('${provider.id}')">
|
||||
<button class="btn btn-primary btn-sm" onclick="window.providersPage.configureProvider('${escapedId}')">
|
||||
<i class="fas fa-cog"></i> Config
|
||||
</button>
|
||||
` : ''}
|
||||
@@ -144,10 +149,17 @@ class ProvidersPage {
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
|
||||
const escapedId = window.api.escapeHtml(provider.id);
|
||||
const escapedName = window.api.escapeHtml(provider.name);
|
||||
const escapedBaseUrl = window.api.escapeHtml(provider.base_url);
|
||||
const escapedBalance = window.api.escapeHtml(provider.credit_balance);
|
||||
const escapedThreshold = window.api.escapeHtml(provider.low_credit_threshold);
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Configure ${provider.name}</h3>
|
||||
<h3 class="modal-title">Configure ${escapedName}</h3>
|
||||
<button class="modal-close" onclick="this.closest('.modal').remove()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -161,7 +173,7 @@ class ProvidersPage {
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="provider-base-url">Base URL</label>
|
||||
<input type="text" id="provider-base-url" value="${provider.base_url || ''}" placeholder="Default API URL">
|
||||
<input type="text" id="provider-base-url" value="${escapedBaseUrl}" placeholder="Default API URL">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="provider-api-key">API Key (Optional / Overwrite)</label>
|
||||
@@ -170,11 +182,11 @@ class ProvidersPage {
|
||||
<div class="grid-2">
|
||||
<div class="form-control">
|
||||
<label for="provider-balance">Current Credit Balance ($)</label>
|
||||
<input type="number" id="provider-balance" value="${provider.credit_balance}" step="0.01">
|
||||
<input type="number" id="provider-balance" value="${escapedBalance}" step="0.01">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="provider-threshold">Low Balance Alert ($)</label>
|
||||
<input type="number" id="provider-threshold" value="${provider.low_credit_threshold}" step="0.50">
|
||||
<input type="number" id="provider-threshold" value="${escapedThreshold}" step="0.50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
|
||||
@@ -279,9 +279,7 @@
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
function escapeHtml(str) {
|
||||
return window.api.escapeHtml(str);
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user