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:
@@ -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
@@ -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, '"')}" 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,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, '"')}"`;
|
||||
return `title="${escapeHtml(str)}"`;
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -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('<script>');
|
||||
expect(escaped).toContain('</script>');
|
||||
});
|
||||
|
||||
it('should escape quotes', () => {
|
||||
const input = 'Value with "quotes" and \'single quotes\'';
|
||||
const escaped = formatValue(input);
|
||||
expect(escaped).toContain('"quotes"');
|
||||
expect(escaped).toContain(''single quotes'');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user