fix: Sanitize all innerHTML dynamic values to prevent XSS

Add centralized escapeHtml() utility and apply it to every dynamic value
inserted via innerHTML/template literals across the frontend. Data from
VRS JSON sources and external CSV files (airline names, countries) flows
through the backend as arbitrary strings that could contain HTML. While
Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those
escapes before the values reach innerHTML — enabling script injection.

Affected modules: aircraft-manager, ui-manager, callsign-manager,
map-manager, and the 3D radar labels in app.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-02-13 15:21:56 +01:00
commit 4a0a993e81
6 changed files with 95 additions and 57 deletions

View file

@ -8,6 +8,7 @@ import { AircraftManager } from './modules/aircraft-manager.js?v=2';
import { MapManager } from './modules/map-manager.js?v=2';
import { UIManager } from './modules/ui-manager.js?v=2';
import { CallsignManager } from './modules/callsign-manager.js';
import { escapeHtml } from './modules/html-utils.js';
// Tile Management for 3D Map Base
class TileManager {
@ -932,10 +933,10 @@ class SkyView {
const callsign = aircraft.Callsign || aircraft.Icao || 'N/A';
const altitude = aircraft.Altitude ? `${Math.round(aircraft.Altitude)}ft` : 'N/A';
const speed = aircraft.GroundSpeed ? `${Math.round(aircraft.GroundSpeed)}kts` : 'N/A';
label.innerHTML = `
<div style="font-weight: bold;">${callsign}</div>
<div style="font-size: 10px; opacity: 0.8;">${altitude} ${speed}</div>
<div style="font-weight: bold;">${escapeHtml(callsign)}</div>
<div style="font-size: 10px; opacity: 0.8;">${escapeHtml(altitude)} ${escapeHtml(speed)}</div>
`;
}

View file

@ -1,4 +1,6 @@
// Aircraft marker and data management module
import { escapeHtml } from './html-utils.js';
export class AircraftManager {
constructor(map, callsignManager = null) {
this.map = map;
@ -437,67 +439,68 @@ export class AircraftManager {
const distance = this.calculateDistance(aircraft);
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
const esc = escapeHtml;
return `
<div class="aircraft-popup">
<div class="popup-header">
<div class="flight-info">
<span class="icao-flag">${flag}</span>
<span class="flight-id">${aircraft.ICAO24 || 'N/A'}</span>
${aircraft.Callsign ? `→ <span class="callsign-loading" data-callsign="${aircraft.Callsign}"><span class="callsign">${aircraft.Callsign}</span></span>` : ''}
<span class="icao-flag">${esc(flag)}</span>
<span class="flight-id">${esc(aircraft.ICAO24) || 'N/A'}</span>
${aircraft.Callsign ? `→ <span class="callsign-loading" data-callsign="${esc(aircraft.Callsign)}"><span class="callsign">${esc(aircraft.Callsign)}</span></span>` : ''}
</div>
</div>
<div class="popup-details">
<div class="detail-row">
<strong>Country:</strong> ${country}
<strong>Country:</strong> ${esc(country)}
</div>
<div class="detail-row">
<strong>Type:</strong> ${type}
<strong>Type:</strong> ${esc(type)}
</div>
${aircraft.TransponderCapability ? `
<div class="detail-row">
<strong>Transponder:</strong> ${aircraft.TransponderCapability}
<strong>Transponder:</strong> ${esc(aircraft.TransponderCapability)}
</div>` : ''}
${aircraft.SignalQuality ? `
<div class="detail-row">
<strong>Signal Quality:</strong> ${aircraft.SignalQuality}
<strong>Signal Quality:</strong> ${esc(aircraft.SignalQuality)}
</div>` : ''}
<div class="detail-grid">
<div class="detail-item">
<div class="label">Altitude:</div>
<div class="value${!altitude ? ' no-data' : ''}">${altitude ? `${altitude} ft | ${altitudeM} m` : 'N/A'}</div>
<div class="value${!altitude ? ' no-data' : ''}">${altitude ? `${esc(altitude)} ft | ${esc(altitudeM)} m` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Squawk:</div>
<div class="value${!aircraft.Squawk ? ' no-data' : ''}">${aircraft.Squawk || 'N/A'}</div>
<div class="value${!aircraft.Squawk ? ' no-data' : ''}">${esc(aircraft.Squawk) || 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Speed:</div>
<div class="value${!aircraft.GroundSpeed ? ' no-data' : ''}">${aircraft.GroundSpeed !== undefined && aircraft.GroundSpeed !== null ? `${aircraft.GroundSpeed} kt | ${speedKmh} km/h` : 'N/A'}</div>
<div class="value${!aircraft.GroundSpeed ? ' no-data' : ''}">${aircraft.GroundSpeed !== undefined && aircraft.GroundSpeed !== null ? `${esc(aircraft.GroundSpeed)} kt | ${esc(speedKmh)} km/h` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Track:</div>
<div class="value${aircraft.Track === undefined || aircraft.Track === null ? ' no-data' : ''}">${aircraft.Track !== undefined && aircraft.Track !== null ? `${aircraft.Track}°` : 'N/A'}</div>
<div class="value${aircraft.Track === undefined || aircraft.Track === null ? ' no-data' : ''}">${aircraft.Track !== undefined && aircraft.Track !== null ? `${esc(aircraft.Track)}°` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">V/Rate:</div>
<div class="value${!aircraft.VerticalRate ? ' no-data' : ''}">${aircraft.VerticalRate ? `${aircraft.VerticalRate} ft/min` : 'N/A'}</div>
<div class="value${!aircraft.VerticalRate ? ' no-data' : ''}">${aircraft.VerticalRate ? `${esc(aircraft.VerticalRate)} ft/min` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Distance:</div>
<div class="value${distance ? '' : ' no-data'}">${distanceKm !== 'N/A' ? `${distanceKm} km` : 'N/A'}</div>
<div class="value${distance ? '' : ' no-data'}">${distanceKm !== 'N/A' ? `${esc(distanceKm)} km` : 'N/A'}</div>
</div>
</div>
<div class="detail-row">
<strong>Position:</strong> ${aircraft.Latitude?.toFixed(4)}°, ${aircraft.Longitude?.toFixed(4)}°
<strong>Position:</strong> ${esc(aircraft.Latitude?.toFixed(4))}°, ${esc(aircraft.Longitude?.toFixed(4))}°
</div>
<div class="detail-row">
<strong>Messages:</strong> ${aircraft.TotalMessages || 0}
<strong>Messages:</strong> ${esc(aircraft.TotalMessages || 0)}
</div>
<div class="detail-row">
<strong>Age:</strong> ${this.calculateAge(aircraft).toFixed(1)}s
<strong>Age:</strong> ${esc(this.calculateAge(aircraft).toFixed(1))}s
</div>
</div>
</div>

View file

@ -1,4 +1,6 @@
// Callsign enrichment and display module
import { escapeHtml } from './html-utils.js';
export class CallsignManager {
constructor() {
this.callsignCache = new Map();
@ -82,36 +84,37 @@ export class CallsignManager {
* @returns {string} - HTML string for display
*/
generateCallsignDisplay(callsignInfo, originalCallsign = '') {
const esc = escapeHtml;
if (!callsignInfo || !callsignInfo.is_valid) {
// Fallback for invalid or missing callsign data
if (originalCallsign) {
return `<span class="callsign-display simple">${originalCallsign}</span>`;
return `<span class="callsign-display simple">${esc(originalCallsign)}</span>`;
}
return '<span class="callsign-display no-data">N/A</span>';
}
const parts = [];
// Airline code
if (callsignInfo.airline_code) {
parts.push(`<span class="airline-code">${callsignInfo.airline_code}</span>`);
parts.push(`<span class="airline-code">${esc(callsignInfo.airline_code)}</span>`);
}
// Flight number
if (callsignInfo.flight_number) {
parts.push(`<span class="flight-number">${callsignInfo.flight_number}</span>`);
parts.push(`<span class="flight-number">${esc(callsignInfo.flight_number)}</span>`);
}
// Airline name (if available)
let airlineInfo = '';
if (callsignInfo.airline_name) {
airlineInfo = `<span class="airline-name" title="${callsignInfo.airline_name}">
${callsignInfo.airline_name}
airlineInfo = `<span class="airline-name" title="${esc(callsignInfo.airline_name)}">
${esc(callsignInfo.airline_name)}
</span>`;
// Add country if available
if (callsignInfo.airline_country) {
airlineInfo += ` <span class="airline-country">(${callsignInfo.airline_country})</span>`;
airlineInfo += ` <span class="airline-country">(${esc(callsignInfo.airline_country)})</span>`;
}
}
@ -130,16 +133,17 @@ export class CallsignManager {
* @returns {string} - Compact HTML for table display
*/
generateCompactCallsignDisplay(callsignInfo, originalCallsign = '') {
const esc = escapeHtml;
if (!callsignInfo || !callsignInfo.is_valid) {
return originalCallsign || 'N/A';
return esc(originalCallsign) || 'N/A';
}
// For tables, use the display_name or format airline + flight
if (callsignInfo.display_name) {
return `<span class="callsign-compact" title="${callsignInfo.airline_name || ''}">${callsignInfo.display_name}</span>`;
return `<span class="callsign-compact" title="${esc(callsignInfo.airline_name || '')}">${esc(callsignInfo.display_name)}</span>`;
}
return `<span class="callsign-compact">${callsignInfo.airline_code} ${callsignInfo.flight_number}</span>`;
return `<span class="callsign-compact">${esc(callsignInfo.airline_code)} ${esc(callsignInfo.flight_number)}</span>`;
}
/**

View file

@ -0,0 +1,22 @@
// HTML sanitization utilities to prevent XSS when using innerHTML
//
// Data from ADS-B/VRS sources and external CSV files (airline names, countries)
// flows through the Go backend as JSON. While json.Marshal escapes < > &,
// JSON.parse() reverses those escapes. Any dynamic value inserted via innerHTML
// or template literals must be escaped to prevent script injection.
/**
* Escape a string for safe insertion into HTML content or attributes.
* Converts the five HTML-significant characters to their entity equivalents.
* @param {*} value - The value to escape (coerced to string, null/undefined become '')
* @returns {string} - HTML-safe string
*/
export function escapeHtml(value) {
if (value == null) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View file

@ -1,4 +1,6 @@
// Map and visualization management module
import { escapeHtml } from './html-utils.js';
export class MapManager {
constructor() {
this.map = null;
@ -177,18 +179,19 @@ export class MapManager {
}
createSourcePopupContent(source, aircraftData) {
const esc = escapeHtml;
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
return `
<div class="source-popup">
<h3>${source.name}</h3>
<p><strong>ID:</strong> ${source.id}</p>
<p><strong>Location:</strong> ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°</p>
<h3>${esc(source.name)}</h3>
<p><strong>ID:</strong> ${esc(source.id)}</p>
<p><strong>Location:</strong> ${esc(source.latitude.toFixed(4))}°, ${esc(source.longitude.toFixed(4))}°</p>
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
<p><strong>Aircraft:</strong> ${aircraftCount}</p>
<p><strong>Messages:</strong> ${source.messages || 0}</p>
<p><strong>Last Seen:</strong> ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}</p>
<p><strong>Aircraft:</strong> ${esc(aircraftCount)}</p>
<p><strong>Messages:</strong> ${esc(source.messages || 0)}</p>
<p><strong>Last Seen:</strong> ${source.last_seen ? esc(new Date(source.last_seen).toLocaleString()) : 'N/A'}</p>
</div>
`;
}
@ -205,11 +208,12 @@ export class MapManager {
);
for (const source of sortedSources) {
const esc = escapeHtml;
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
<span title="${source.host}:${source.port}">${source.name}</span>
<span title="${esc(source.host)}:${esc(source.port)}">${esc(source.name)}</span>
`;
legend.appendChild(item);
}

View file

@ -1,4 +1,6 @@
// UI and table management module
import { escapeHtml } from './html-utils.js';
export class UIManager {
constructor() {
this.aircraftData = new Map();
@ -118,18 +120,19 @@ export class UIManager {
const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0;
const bestSignal = this.getBestSignalFromSources(aircraft.sources);
const esc = escapeHtml;
const row = document.createElement('tr');
row.innerHTML = `
<td><span class="type-badge ${type}">${icao}</span></td>
<td>${aircraft.Callsign || '-'}</td>
<td><span class="type-badge ${esc(type)}">${esc(icao)}</span></td>
<td>${esc(aircraft.Callsign) || '-'}</td>
<td>${this.formatSquawk(aircraft)}</td>
<td>${altitude ? `${altitude} ft` : '-'}</td>
<td>${aircraft.GroundSpeed || '-'} kt</td>
<td>${distance ? distance.toFixed(1) : '-'} km</td>
<td>${aircraft.Track || '-'}°</td>
<td>${sources}</td>
<td><span class="${this.getSignalClass(bestSignal)}">${bestSignal ? bestSignal.toFixed(1) : '-'}</span></td>
<td>${this.calculateAge(aircraft).toFixed(0)}s</td>
<td>${altitude ? `${esc(altitude)} ft` : '-'}</td>
<td>${esc(aircraft.GroundSpeed) || '-'} kt</td>
<td>${distance ? esc(distance.toFixed(1)) : '-'} km</td>
<td>${esc(aircraft.Track) || '-'}°</td>
<td>${esc(sources)}</td>
<td><span class="${esc(this.getSignalClass(bestSignal))}">${bestSignal ? esc(bestSignal.toFixed(1)) : '-'}</span></td>
<td>${esc(this.calculateAge(aircraft).toFixed(0))}s</td>
`;
row.addEventListener('click', () => {
@ -192,29 +195,30 @@ export class UIManager {
formatSquawk(aircraft) {
if (!aircraft.Squawk) return '-';
const esc = escapeHtml;
// If we have a description, format it nicely
if (aircraft.SquawkDescription) {
// Check if it's an emergency code (contains warning emoji)
if (aircraft.SquawkDescription.includes('⚠️')) {
return `<span class="squawk-emergency" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
return `<span class="squawk-emergency" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
// Check if it's a special code (contains special emoji)
else if (aircraft.SquawkDescription.includes('🔸')) {
return `<span class="squawk-special" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
return `<span class="squawk-special" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
// Check if it's a military code (contains military emoji)
else if (aircraft.SquawkDescription.includes('🔰')) {
return `<span class="squawk-military" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
return `<span class="squawk-military" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
// Standard codes
else {
return `<span class="squawk-standard" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
return `<span class="squawk-standard" title="${esc(aircraft.SquawkDescription)}">${esc(aircraft.Squawk)}</span>`;
}
}
// No description available, show just the code
return aircraft.Squawk;
return esc(aircraft.Squawk);
}
updateSourceFilter() {