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');
}