diff --git a/public/index.html b/public/index.html index c71207f..844b7f8 100644 --- a/public/index.html +++ b/public/index.html @@ -590,19 +590,37 @@ const container = document.querySelector('#panel-audit .panel-content'); const source = entry.source || ''; - const sourceClass = 'audit-source-' + source; + // Restrict the source-derived class to a safe token; source is + // attacker-controlled and would otherwise break out of the attribute. + const sourceClass = 'audit-source-' + source.replace(/[^a-zA-Z0-9_-]/g, '-'); const ts = formatTimestamp(entry.timestamp); const denied = entry.event_type === 'request.denied'; - const html = - '' + esc(ts) + '' + - '' + esc(source) + '' + - '' + esc(entry.event_type || '') + '' + - '' + esc(entry.summary || buildSummary(entry)) + ''; - const row = document.createElement('div'); row.className = 'audit-row' + (denied ? ' audit-denied' : ''); - row.innerHTML = html; + + // Build via DOM APIs + textContent so untrusted values can never be + // interpreted as HTML. + const tsSpan = document.createElement('span'); + tsSpan.className = 'audit-ts'; + tsSpan.textContent = ts; + + const sourceSpan = document.createElement('span'); + sourceSpan.className = 'audit-source ' + sourceClass; + sourceSpan.textContent = source; + + const eventSpan = document.createElement('span'); + eventSpan.className = 'audit-event'; + eventSpan.textContent = entry.event_type || ''; + + const summarySpan = document.createElement('span'); + summarySpan.className = 'audit-summary'; + summarySpan.textContent = entry.summary || buildSummary(entry); + + row.appendChild(tsSpan); + row.appendChild(sourceSpan); + row.appendChild(eventSpan); + row.appendChild(summarySpan); container.appendChild(row); // Auto-scroll diff --git a/server.js b/server.js index d8f68d3..cc9ef70 100644 --- a/server.js +++ b/server.js @@ -62,15 +62,27 @@ function broadcastEvent(event) { // Serve static files from /app/public (+ shared theme.css) function serveStatic(req, res) { const publicDir = path.join(__dirname, 'public'); + + // Decode and strip the query/fragment before touching the filesystem so + // encoded traversal (e.g. %2e%2e) can't slip past the boundary check. + let pathname; + try { + pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + } catch (_err) { + res.writeHead(400); + return res.end('Bad request'); + } + let filePath; - if (req.url === '/theme.css') { + if (pathname === '/theme.css') { filePath = path.join(__dirname, 'theme.css'); - } else if (req.url === '/registry-panel.js') { + } else if (pathname === '/registry-panel.js') { filePath = path.join(__dirname, 'registry-panel.js'); } else { - filePath = path.join(publicDir, req.url === '/' ? 'index.html' : req.url); - // Prevent directory traversal (only for public dir paths) - if (!filePath.startsWith(publicDir)) { + // Resolve the request against publicDir and confirm it stays inside it. + const rel = pathname === '/' ? 'index.html' : '.' + path.posix.normalize(pathname); + filePath = path.resolve(publicDir, rel); + if (filePath !== publicDir && !filePath.startsWith(publicDir + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }