Skip to content

Commit 7840901

Browse files
committed
🔒 fix(dashboard): patch stored XSS in audit log and path traversal in static server
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.
1 parent 890679a commit 7840901

2 files changed

Lines changed: 43 additions & 13 deletions

File tree

‎public/index.html‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -590,19 +590,37 @@
590590
const container = document.querySelector('#panel-audit .panel-content');
591591

592592
const source = entry.source || '';
593-
const sourceClass = 'audit-source-' + source;
593+
// Restrict the source-derived class to a safe token; source is
594+
// attacker-controlled and would otherwise break out of the attribute.
595+
const sourceClass = 'audit-source-' + source.replace(/[^a-zA-Z0-9_-]/g, '-');
594596
const ts = formatTimestamp(entry.timestamp);
595597
const denied = entry.event_type === 'request.denied';
596598

597-
const html =
598-
'<span class="audit-ts">' + esc(ts) + '</span>' +
599-
'<span class="audit-source ' + sourceClass + '">' + esc(source) + '</span>' +
600-
'<span class="audit-event">' + esc(entry.event_type || '') + '</span>' +
601-
'<span class="audit-summary">' + esc(entry.summary || buildSummary(entry)) + '</span>';
602-
603599
const row = document.createElement('div');
604600
row.className = 'audit-row' + (denied ? ' audit-denied' : '');
605-
row.innerHTML = html;
601+
602+
// Build via DOM APIs + textContent so untrusted values can never be
603+
// interpreted as HTML.
604+
const tsSpan = document.createElement('span');
605+
tsSpan.className = 'audit-ts';
606+
tsSpan.textContent = ts;
607+
608+
const sourceSpan = document.createElement('span');
609+
sourceSpan.className = 'audit-source ' + sourceClass;
610+
sourceSpan.textContent = source;
611+
612+
const eventSpan = document.createElement('span');
613+
eventSpan.className = 'audit-event';
614+
eventSpan.textContent = entry.event_type || '';
615+
616+
const summarySpan = document.createElement('span');
617+
summarySpan.className = 'audit-summary';
618+
summarySpan.textContent = entry.summary || buildSummary(entry);
619+
620+
row.appendChild(tsSpan);
621+
row.appendChild(sourceSpan);
622+
row.appendChild(eventSpan);
623+
row.appendChild(summarySpan);
606624
container.appendChild(row);
607625

608626
// Auto-scroll

‎server.js‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,27 @@ function broadcastEvent(event) {
6262
// Serve static files from /app/public (+ shared theme.css)
6363
function serveStatic(req, res) {
6464
const publicDir = path.join(__dirname, 'public');
65+
66+
// Decode and strip the query/fragment before touching the filesystem so
67+
// encoded traversal (e.g. %2e%2e) can't slip past the boundary check.
68+
let pathname;
69+
try {
70+
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
71+
} catch (_err) {
72+
res.writeHead(400);
73+
return res.end('Bad request');
74+
}
75+
6576
let filePath;
66-
if (req.url === '/theme.css') {
77+
if (pathname === '/theme.css') {
6778
filePath = path.join(__dirname, 'theme.css');
68-
} else if (req.url === '/registry-panel.js') {
79+
} else if (pathname === '/registry-panel.js') {
6980
filePath = path.join(__dirname, 'registry-panel.js');
7081
} else {
71-
filePath = path.join(publicDir, req.url === '/' ? 'index.html' : req.url);
72-
// Prevent directory traversal (only for public dir paths)
73-
if (!filePath.startsWith(publicDir)) {
82+
// Resolve the request against publicDir and confirm it stays inside it.
83+
const rel = pathname === '/' ? 'index.html' : '.' + path.posix.normalize(pathname);
84+
filePath = path.resolve(publicDir, rel);
85+
if (filePath !== publicDir && !filePath.startsWith(publicDir + path.sep)) {
7486
res.writeHead(403);
7587
return res.end('Forbidden');
7688
}

0 commit comments

Comments
 (0)