feat(security): sanitize user input in info panel to prevent XSS

- Add `escapeHtml` utility to `src/shared/utils.ts`
- Sanitize rendered values in `src/info-panel/ValueFormatter.ts`
- Sanitize node titles, types, and labels in `src/info-panel/UIManager.ts`
- Add unit tests verifying XSS prevention in `tests/unit/Security.test.ts`
This commit is contained in:
google-labs-jules[bot]
2026-01-14 04:38:38 +00:00
parent 9677722f43
commit 416090376e
5 changed files with 62 additions and 13 deletions
+6
View File
@@ -0,0 +1,6 @@
# Sentinel's Journal
## 2026-01-06 - Improper DOM Manipulation XSS
**Vulnerability:** Found multiple instances of `innerHTML` being used with unsanitized user inputs (Node titles, widget values) in `UIManager.ts`.
**Learning:** This project uses direct DOM manipulation without a framework, making XSS a primary risk. Developers were manually building HTML strings.
**Prevention:** Introduced `escapeHtml` utility. Any new code using `innerHTML` MUST sanitize inputs. Prefer `textContent` where possible, or use the `escapeHtml` helper.
+11 -10
View File
@@ -8,6 +8,7 @@
import { StateManager } from './StateManager';
import { Icons } from '../shared/icons';
import { Logger } from '../shared/logger';
import { escapeHtml } from '../shared/utils';
import { formatValue, getValueClass, getValueAttributes, formatWidgetValue } from './ValueFormatter';
import {
getCheckpointInfo,
@@ -872,11 +873,11 @@ export class UIManager {
}
this.elements.content.innerHTML = sections.map(section => `
<div class="info-section" data-section="${section.id}">
<div class="section-header" data-section="${section.id}">
<div class="info-section" data-section="${escapeHtml(section.id)}">
<div class="section-header" data-section="${escapeHtml(section.id)}">
<span class="section-icon">${section.icon}</span>
<span class="section-title">${section.title}</span>
${section.badge ? `<span class="section-badge">${section.badge}</span>` : ''}
<span class="section-title">${escapeHtml(section.title)}</span>
${section.badge ? `<span class="section-badge">${escapeHtml(section.badge)}</span>` : ''}
${section.id !== 'node' ? `<span class="expand-icon">${Icons.chevronRight}</span>` : ''}
</div>
<div class="section-content">
@@ -894,7 +895,7 @@ export class UIManager {
const rawValue = String(item.value || '');
const showCopyBtn = !item.clickable && rawValue.length > 3 && typeof item.value === 'string';
const copyBtnHtml = showCopyBtn ? `
<button class="copy-btn" data-copy-value="${rawValue.replace(/"/g, '&quot;')}" title="Copy to clipboard">
<button class="copy-btn" data-copy-value="${escapeHtml(rawValue)}" title="Copy to clipboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
@@ -902,7 +903,7 @@ export class UIManager {
</button>` : '';
return `
<div class="info-row ${clickableClass}" ${clickableAttr} ${nodeIdAttr} style="${item.clickable ? 'cursor: pointer;' : ''}">
<span class="info-label">${item.label}</span>
<span class="info-label">${escapeHtml(item.label)}</span>
<span class="info-value ${valueClass}" ${valueAttributes}>${value}${dropdownIcon}</span>
${copyBtnHtml}
</div>`;
@@ -1126,13 +1127,13 @@ export class UIManager {
const execNode = node as NodeExecOrderEntry;
item.innerHTML = `
<span style="color: var(--info-panel-accent-color, #4ecdc4); font-weight: 600; min-width: 24px;">#${execNode.order}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${execNode.title} (#${execNode.id})</span>
<span style="color: #888; font-size: 11px;">${execNode.type}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(execNode.title)} (#${execNode.id})</span>
<span style="color: #888; font-size: 11px;">${escapeHtml(execNode.type)}</span>
`;
} else {
item.innerHTML = `
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${node.title} (#${node.id})</span>
<span style="color: #888; font-size: 11px;">${node.type}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(node.title)} (#${node.id})</span>
<span style="color: #888; font-size: 11px;">${escapeHtml(node.type)}</span>
`;
}
+5 -3
View File
@@ -5,6 +5,8 @@
* Extracted from UIManager.ts for better modularity.
*/
import { escapeHtml } from '../shared/utils';
/**
* Format a value for display in the info panel.
* @param value - The value to format
@@ -26,11 +28,11 @@ export function formatValue(value: unknown, label?: string): string {
label.toLowerCase().includes('positive') ||
label.toLowerCase().includes('negative')
)) {
return str;
return escapeHtml(str);
}
// Show full text for very long values (no truncation)
return str;
return escapeHtml(str);
}
/**
@@ -64,7 +66,7 @@ export function getValueAttributes(value: unknown): string {
const str = String(value);
if (str.length > 500) { // Only for extremely long text
return `title="${str.replace(/"/g, '&quot;')}"`;
return `title="${escapeHtml(str)}"`;
}
return '';
+15
View File
@@ -159,3 +159,18 @@ export function createDebugLogger(
}
};
}
/**
* Escapes HTML special characters in a string to prevent XSS.
* @param str - The string to escape
* @returns Escaped string
*/
export function escapeHtml(str: string): string {
if (!str) return str;
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { formatValue } from '../../src/info-panel/ValueFormatter';
describe('Security', () => {
describe('formatValue', () => {
it('should escape HTML in values to prevent XSS', () => {
const maliciousInput = '<script>alert("xss")</script>';
const escaped = formatValue(maliciousInput);
// Assert that the output does NOT contain the raw script tag
expect(escaped).not.toContain('<script>');
// Assert that the output IS escaped
expect(escaped).toContain('&lt;script&gt;');
expect(escaped).toContain('&lt;/script&gt;');
});
it('should escape quotes', () => {
const input = 'Value with "quotes" and \'single quotes\'';
const escaped = formatValue(input);
expect(escaped).toContain('&quot;quotes&quot;');
expect(escaped).toContain('&#039;single quotes&#039;');
});
});
});