From 4a0a993e81a47a9a5eff947ab25361c726100b85 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 13 Feb 2026 15:21:56 +0100 Subject: [PATCH] fix: Sanitize all innerHTML dynamic values to prevent XSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/static/js/app.js | 7 ++-- assets/static/js/modules/aircraft-manager.js | 41 +++++++++++--------- assets/static/js/modules/callsign-manager.js | 26 +++++++------ assets/static/js/modules/html-utils.js | 22 +++++++++++ assets/static/js/modules/map-manager.js | 20 ++++++---- assets/static/js/modules/ui-manager.js | 36 +++++++++-------- 6 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 assets/static/js/modules/html-utils.js diff --git a/assets/static/js/app.js b/assets/static/js/app.js index 836feb0..dd94eaa 100644 --- a/assets/static/js/app.js +++ b/assets/static/js/app.js @@ -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 = ` -
${callsign}
-
${altitude} • ${speed}
+
${escapeHtml(callsign)}
+
${escapeHtml(altitude)} • ${escapeHtml(speed)}
`; } diff --git a/assets/static/js/modules/aircraft-manager.js b/assets/static/js/modules/aircraft-manager.js index 3f018c6..06d3f5f 100644 --- a/assets/static/js/modules/aircraft-manager.js +++ b/assets/static/js/modules/aircraft-manager.js @@ -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 `
- +
diff --git a/assets/static/js/modules/callsign-manager.js b/assets/static/js/modules/callsign-manager.js index ef9a089..7556288 100644 --- a/assets/static/js/modules/callsign-manager.js +++ b/assets/static/js/modules/callsign-manager.js @@ -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 `${originalCallsign}`; + return `${esc(originalCallsign)}`; } return 'N/A'; } const parts = []; - + // Airline code if (callsignInfo.airline_code) { - parts.push(`${callsignInfo.airline_code}`); + parts.push(`${esc(callsignInfo.airline_code)}`); } // Flight number if (callsignInfo.flight_number) { - parts.push(`${callsignInfo.flight_number}`); + parts.push(`${esc(callsignInfo.flight_number)}`); } // Airline name (if available) let airlineInfo = ''; if (callsignInfo.airline_name) { - airlineInfo = ` - ${callsignInfo.airline_name} + airlineInfo = ` + ${esc(callsignInfo.airline_name)} `; - + // Add country if available if (callsignInfo.airline_country) { - airlineInfo += ` (${callsignInfo.airline_country})`; + airlineInfo += ` (${esc(callsignInfo.airline_country)})`; } } @@ -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 `${callsignInfo.display_name}`; + return `${esc(callsignInfo.display_name)}`; } - return `${callsignInfo.airline_code} ${callsignInfo.flight_number}`; + return `${esc(callsignInfo.airline_code)} ${esc(callsignInfo.flight_number)}`; } /** diff --git a/assets/static/js/modules/html-utils.js b/assets/static/js/modules/html-utils.js new file mode 100644 index 0000000..ac5bd11 --- /dev/null +++ b/assets/static/js/modules/html-utils.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/assets/static/js/modules/map-manager.js b/assets/static/js/modules/map-manager.js index 3149e51..dd754d1 100644 --- a/assets/static/js/modules/map-manager.js +++ b/assets/static/js/modules/map-manager.js @@ -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 `
-

${source.name}

-

ID: ${source.id}

-

Location: ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°

+

${esc(source.name)}

+

ID: ${esc(source.id)}

+

Location: ${esc(source.latitude.toFixed(4))}°, ${esc(source.longitude.toFixed(4))}°

Status: ${source.active ? 'Active' : 'Inactive'}

-

Aircraft: ${aircraftCount}

-

Messages: ${source.messages || 0}

-

Last Seen: ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}

+

Aircraft: ${esc(aircraftCount)}

+

Messages: ${esc(source.messages || 0)}

+

Last Seen: ${source.last_seen ? esc(new Date(source.last_seen).toLocaleString()) : 'N/A'}

`; } @@ -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 = ` - ${source.name} + ${esc(source.name)} `; legend.appendChild(item); } diff --git a/assets/static/js/modules/ui-manager.js b/assets/static/js/modules/ui-manager.js index 9bb8618..374e8b7 100644 --- a/assets/static/js/modules/ui-manager.js +++ b/assets/static/js/modules/ui-manager.js @@ -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 = ` - ${icao} - ${aircraft.Callsign || '-'} + ${esc(icao)} + ${esc(aircraft.Callsign) || '-'} ${this.formatSquawk(aircraft)} - ${altitude ? `${altitude} ft` : '-'} - ${aircraft.GroundSpeed || '-'} kt - ${distance ? distance.toFixed(1) : '-'} km - ${aircraft.Track || '-'}° - ${sources} - ${bestSignal ? bestSignal.toFixed(1) : '-'} - ${this.calculateAge(aircraft).toFixed(0)}s + ${altitude ? `${esc(altitude)} ft` : '-'} + ${esc(aircraft.GroundSpeed) || '-'} kt + ${distance ? esc(distance.toFixed(1)) : '-'} km + ${esc(aircraft.Track) || '-'}° + ${esc(sources)} + ${bestSignal ? esc(bestSignal.toFixed(1)) : '-'} + ${esc(this.calculateAge(aircraft).toFixed(0))}s `; 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 `${aircraft.Squawk}`; + return `${esc(aircraft.Squawk)}`; } // Check if it's a special code (contains special emoji) else if (aircraft.SquawkDescription.includes('🔸')) { - return `${aircraft.Squawk}`; + return `${esc(aircraft.Squawk)}`; } // Check if it's a military code (contains military emoji) else if (aircraft.SquawkDescription.includes('🔰')) { - return `${aircraft.Squawk}`; + return `${esc(aircraft.Squawk)}`; } // Standard codes else { - return `${aircraft.Squawk}`; + return `${esc(aircraft.Squawk)}`; } } - + // No description available, show just the code - return aircraft.Squawk; + return esc(aircraft.Squawk); } updateSourceFilter() {