From 784090195d17ab54746378f399b96cc3bd303bea Mon Sep 17 00:00:00 2001 From: Luis Cosio Date: Sun, 19 Jul 2026 19:31:06 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20fix(dashboard):=20patch=20stored?= =?UTF-8?q?=20XSS=20in=20audit=20log=20and=20path=20traversal=20in=20stati?= =?UTF-8?q?c=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves two CodeQL code-scanning alerts. js/xss (public/index.html): appendAuditEntry built the audit row via innerHTML, and the attacker-controlled `entry.source` was concatenated raw into a class attribute (`sourceClass`) with no escaping. A crafted `source` could break out of the attribute and inject markup. Rebuild the row with createElement + textContent so untrusted values can never be parsed as HTML, and restrict the source-derived CSS class to a safe token. js/path-injection (server.js): serveStatic relied on a startsWith guard over a path.join result, which does not decode %2e%2e or collapse encoded traversal. Decode the URL, strip the query, normalize, resolve against publicDir, and reject anything escaping the root. Verified: npm test 25/25; traversal probes (/../server.js, /%2e%2e/server.js, encoded variants) all 404 while normal assets serve; injected audit payload renders as inert text with no script execution. --- public/index.html | 34 ++++++++++++++++++++++++++-------- server.js | 22 +++++++++++++++++----- 2 files changed, 43 insertions(+), 13 deletions(-) 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'); }