Major security and code quality improvements

Security:
- Add input validation for all API endpoints (frequency, lat/lon, device, gain, ppm)
- Add HTML escaping utility to prevent XSS attacks
- Add path traversal protection for log file configuration
- Add proper HTTP status codes for error responses (400, 409, 503)

Performance:
- Reduce SSE keepalive overhead (30s interval instead of 1s)
- Add centralized SSE stream utility with optimized keepalive
- Add DataStore class for thread-safe data with automatic cleanup

New Features:
- Add data export endpoints (/export/aircraft, /export/wifi, /export/bluetooth)
- Support for both JSON and CSV export formats
- Add process cleanup on application exit (atexit handlers)
- Label Iridium module as demo mode with clear warnings

Code Quality:
- Create utils/validation.py for centralized input validation
- Create utils/sse.py for SSE stream utilities
- Create utils/cleanup.py for memory management
- Add safe_terminate() and register_process() for process management
- Improve error handling with proper logging throughout routes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James Smith
2025-12-30 19:24:40 +00:00
parent b44546af53
commit 1398a5dedd
13 changed files with 964 additions and 63 deletions
+29 -1
View File
@@ -1,6 +1,15 @@
# Utility modules for INTERCEPT
from .dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from .process import cleanup_stale_processes, is_valid_mac, is_valid_channel, detect_devices
from .process import (
cleanup_stale_processes,
is_valid_mac,
is_valid_channel,
detect_devices,
safe_terminate,
register_process,
unregister_process,
cleanup_all_processes,
)
from .logging import (
get_logger,
app_logger,
@@ -12,3 +21,22 @@ from .logging import (
satellite_logger,
iridium_logger,
)
from .validation import (
escape_html,
validate_latitude,
validate_longitude,
validate_frequency,
validate_device_index,
validate_gain,
validate_ppm,
validate_hours,
validate_elevation,
validate_wifi_channel,
validate_mac_address,
validate_positive_int,
sanitize_callsign,
sanitize_ssid,
sanitize_device_name,
)
from .sse import sse_stream, format_sse, clear_queue
from .cleanup import DataStore, CleanupManager, cleanup_manager, cleanup_dict
+241
View File
@@ -0,0 +1,241 @@
"""Data cleanup utilities for stale entries."""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger('intercept.cleanup')
class DataStore:
"""Thread-safe data store with automatic cleanup of stale entries."""
def __init__(self, max_age_seconds: float = 300.0, name: str = 'data'):
"""
Initialize data store.
Args:
max_age_seconds: Maximum age of entries before cleanup (default 5 minutes)
name: Name for logging purposes
"""
self.data: dict[str, Any] = {}
self.timestamps: dict[str, float] = {}
self.max_age = max_age_seconds
self.name = name
self._lock = threading.Lock()
def set(self, key: str, value: Any) -> None:
"""Add or update an entry."""
with self._lock:
self.data[key] = value
self.timestamps[key] = time.time()
def get(self, key: str, default: Any = None) -> Any:
"""Get an entry."""
with self._lock:
return self.data.get(key, default)
def update(self, key: str, updates: dict) -> None:
"""Update an existing entry with new values."""
with self._lock:
if key in self.data:
if isinstance(self.data[key], dict):
self.data[key].update(updates)
else:
self.data[key] = updates
else:
self.data[key] = updates
self.timestamps[key] = time.time()
def touch(self, key: str) -> None:
"""Update timestamp for an entry without changing data."""
with self._lock:
if key in self.data:
self.timestamps[key] = time.time()
def delete(self, key: str) -> bool:
"""Delete an entry."""
with self._lock:
if key in self.data:
del self.data[key]
del self.timestamps[key]
return True
return False
def clear(self) -> None:
"""Clear all entries."""
with self._lock:
self.data.clear()
self.timestamps.clear()
def all(self) -> dict[str, Any]:
"""Get a copy of all data."""
with self._lock:
return dict(self.data)
def keys(self) -> list[str]:
"""Get all keys."""
with self._lock:
return list(self.data.keys())
def values(self) -> list[Any]:
"""Get all values."""
with self._lock:
return list(self.data.values())
def items(self) -> list[tuple[str, Any]]:
"""Get all items."""
with self._lock:
return list(self.data.items())
def __len__(self) -> int:
with self._lock:
return len(self.data)
def __contains__(self, key: str) -> bool:
with self._lock:
return key in self.data
def cleanup(self) -> int:
"""
Remove entries older than max_age.
Returns:
Number of entries removed
"""
now = time.time()
expired = []
with self._lock:
for key, timestamp in self.timestamps.items():
if now - timestamp > self.max_age:
expired.append(key)
for key in expired:
del self.data[key]
del self.timestamps[key]
if expired:
logger.debug(f"{self.name}: Cleaned up {len(expired)} stale entries")
return len(expired)
class CleanupManager:
"""Manages periodic cleanup of multiple data stores."""
def __init__(self, interval: float = 60.0):
"""
Initialize cleanup manager.
Args:
interval: Cleanup interval in seconds
"""
self.stores: list[DataStore] = []
self.interval = interval
self._timer: threading.Timer | None = None
self._running = False
self._lock = threading.Lock()
def register(self, store: DataStore) -> None:
"""Register a data store for cleanup."""
with self._lock:
if store not in self.stores:
self.stores.append(store)
def unregister(self, store: DataStore) -> None:
"""Unregister a data store."""
with self._lock:
if store in self.stores:
self.stores.remove(store)
def start(self) -> None:
"""Start the cleanup timer."""
with self._lock:
if self._running:
return
self._running = True
self._schedule_cleanup()
def stop(self) -> None:
"""Stop the cleanup timer."""
with self._lock:
self._running = False
if self._timer:
self._timer.cancel()
self._timer = None
def _schedule_cleanup(self) -> None:
"""Schedule the next cleanup."""
if not self._running:
return
self._timer = threading.Timer(self.interval, self._run_cleanup)
self._timer.daemon = True
self._timer.start()
def _run_cleanup(self) -> None:
"""Run cleanup on all registered stores."""
total_cleaned = 0
with self._lock:
stores = list(self.stores)
for store in stores:
try:
total_cleaned += store.cleanup()
except Exception as e:
logger.error(f"Error cleaning up {store.name}: {e}")
if total_cleaned > 0:
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
self._schedule_cleanup()
def cleanup_now(self) -> int:
"""Run cleanup immediately."""
total = 0
with self._lock:
stores = list(self.stores)
for store in stores:
try:
total += store.cleanup()
except Exception as e:
logger.error(f"Error cleaning up {store.name}: {e}")
return total
# Global cleanup manager
cleanup_manager = CleanupManager(interval=60.0)
def cleanup_dict(
data: dict[str, Any],
timestamps: dict[str, float],
max_age_seconds: float = 300.0
) -> list[str]:
"""
Clean up stale entries from a dictionary.
Args:
data: Dictionary to clean
timestamps: Dictionary of key -> last_seen timestamp
max_age_seconds: Maximum age in seconds
Returns:
List of removed keys
"""
now = time.time()
expired = []
for key, timestamp in list(timestamps.items()):
if now - timestamp > max_age_seconds:
expired.append(key)
for key in expired:
data.pop(key, None)
timestamps.pop(key, None)
return expired
+93 -1
View File
@@ -1,11 +1,103 @@
from __future__ import annotations
import atexit
import logging
import signal
import subprocess
import re
from typing import Any
import threading
import time
from typing import Any, Callable
from .dependencies import check_tool
logger = logging.getLogger('intercept.process')
# Track all spawned processes for cleanup
_spawned_processes: list[subprocess.Popen] = []
_process_lock = threading.Lock()
def register_process(process: subprocess.Popen) -> None:
"""Register a spawned process for cleanup on exit."""
with _process_lock:
_spawned_processes.append(process)
def unregister_process(process: subprocess.Popen) -> None:
"""Unregister a process from cleanup list."""
with _process_lock:
if process in _spawned_processes:
_spawned_processes.remove(process)
def cleanup_all_processes() -> None:
"""Clean up all registered processes on exit."""
logger.info("Cleaning up all spawned processes...")
with _process_lock:
for process in _spawned_processes:
if process and process.poll() is None:
try:
process.terminate()
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
except Exception as e:
logger.warning(f"Error cleaning up process: {e}")
_spawned_processes.clear()
def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bool:
"""
Safely terminate a process.
Args:
process: Process to terminate
timeout: Seconds to wait before killing
Returns:
True if process was terminated, False if already dead or None
"""
if not process:
return False
if process.poll() is not None:
# Already dead
unregister_process(process)
return False
try:
process.terminate()
process.wait(timeout=timeout)
unregister_process(process)
return True
except subprocess.TimeoutExpired:
process.kill()
unregister_process(process)
return True
except Exception as e:
logger.warning(f"Error terminating process: {e}")
return False
# Register cleanup handlers
atexit.register(cleanup_all_processes)
# Handle signals for graceful shutdown
def _signal_handler(signum, frame):
"""Handle termination signals."""
logger.info(f"Received signal {signum}, cleaning up...")
cleanup_all_processes()
# Only register signal handlers if we're not in a thread
try:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
except ValueError:
# Can't set signal handlers from a thread
pass
def cleanup_stale_processes() -> None:
"""Kill any stale processes from previous runs (but not system services)."""
+89
View File
@@ -0,0 +1,89 @@
"""Server-Sent Events (SSE) utilities."""
from __future__ import annotations
import json
import queue
import time
from typing import Any, Generator
def sse_stream(
data_queue: queue.Queue,
timeout: float = 1.0,
keepalive_interval: float = 30.0,
stop_check: callable = None
) -> Generator[str, None, None]:
"""
Generate SSE stream from a queue.
Args:
data_queue: Queue to read messages from
timeout: Queue get timeout in seconds
keepalive_interval: Seconds between keepalive messages
stop_check: Optional callable that returns True to stop the stream
Yields:
SSE formatted strings
"""
last_keepalive = time.time()
while True:
# Check if we should stop
if stop_check and stop_check():
break
try:
msg = data_queue.get(timeout=timeout)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
# Send keepalive if enough time has passed
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str:
"""
Format data as SSE message.
Args:
data: Data to send (will be JSON encoded if dict)
event: Optional event name
Returns:
SSE formatted string
"""
if isinstance(data, dict):
data = json.dumps(data)
lines = []
if event:
lines.append(f"event: {event}")
lines.append(f"data: {data}")
lines.append("")
lines.append("")
return '\n'.join(lines)
def clear_queue(q: queue.Queue) -> int:
"""
Clear all items from a queue.
Args:
q: Queue to clear
Returns:
Number of items cleared
"""
count = 0
while True:
try:
q.get_nowait()
count += 1
except queue.Empty:
break
return count
+171
View File
@@ -0,0 +1,171 @@
"""Input validation utilities for API endpoints."""
from __future__ import annotations
import re
from typing import Any
def escape_html(text: str | None) -> str:
"""Escape HTML special characters to prevent XSS attacks."""
if text is None:
return ''
if not isinstance(text, str):
text = str(text)
html_escape_table = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
return ''.join(html_escape_table.get(c, c) for c in text)
def validate_latitude(lat: Any) -> float:
"""Validate and return latitude value."""
try:
lat_float = float(lat)
if not -90 <= lat_float <= 90:
raise ValueError(f"Latitude must be between -90 and 90, got {lat_float}")
return lat_float
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid latitude: {lat}") from e
def validate_longitude(lon: Any) -> float:
"""Validate and return longitude value."""
try:
lon_float = float(lon)
if not -180 <= lon_float <= 180:
raise ValueError(f"Longitude must be between -180 and 180, got {lon_float}")
return lon_float
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid longitude: {lon}") from e
def validate_frequency(freq: Any, min_mhz: float = 24.0, max_mhz: float = 1766.0) -> float:
"""Validate and return frequency in MHz."""
try:
freq_float = float(freq)
if not min_mhz <= freq_float <= max_mhz:
raise ValueError(f"Frequency must be between {min_mhz} and {max_mhz} MHz, got {freq_float}")
return freq_float
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid frequency: {freq}") from e
def validate_device_index(device: Any) -> int:
"""Validate and return RTL-SDR device index."""
try:
device_int = int(device)
if not 0 <= device_int <= 255:
raise ValueError(f"Device index must be between 0 and 255, got {device_int}")
return device_int
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid device index: {device}") from e
def validate_gain(gain: Any) -> float:
"""Validate and return gain value."""
try:
gain_float = float(gain)
if not 0 <= gain_float <= 50:
raise ValueError(f"Gain must be between 0 and 50, got {gain_float}")
return gain_float
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid gain: {gain}") from e
def validate_ppm(ppm: Any) -> int:
"""Validate and return PPM correction value."""
try:
ppm_int = int(ppm)
if not -1000 <= ppm_int <= 1000:
raise ValueError(f"PPM must be between -1000 and 1000, got {ppm_int}")
return ppm_int
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid PPM: {ppm}") from e
def validate_hours(hours: Any, min_hours: int = 1, max_hours: int = 168) -> int:
"""Validate and return hours value (for satellite predictions)."""
try:
hours_int = int(hours)
if not min_hours <= hours_int <= max_hours:
raise ValueError(f"Hours must be between {min_hours} and {max_hours}, got {hours_int}")
return hours_int
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid hours: {hours}") from e
def validate_elevation(elevation: Any) -> float:
"""Validate and return elevation angle."""
try:
el_float = float(elevation)
if not 0 <= el_float <= 90:
raise ValueError(f"Elevation must be between 0 and 90, got {el_float}")
return el_float
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid elevation: {elevation}") from e
def validate_wifi_channel(channel: Any) -> int:
"""Validate and return WiFi channel."""
try:
ch_int = int(channel)
# Valid WiFi channels: 1-14 (2.4GHz), 32-177 (5GHz)
valid_2ghz = 1 <= ch_int <= 14
valid_5ghz = 32 <= ch_int <= 177
if not (valid_2ghz or valid_5ghz):
raise ValueError(f"Invalid WiFi channel: {ch_int}")
return ch_int
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid WiFi channel: {channel}") from e
def validate_mac_address(mac: Any) -> str:
"""Validate and return MAC address."""
if not mac or not isinstance(mac, str):
raise ValueError("MAC address is required")
mac = mac.upper().strip()
if not re.match(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$', mac):
raise ValueError(f"Invalid MAC address format: {mac}")
return mac
def validate_positive_int(value: Any, name: str = 'value', max_val: int | None = None) -> int:
"""Validate and return a positive integer."""
try:
val_int = int(value)
if val_int < 0:
raise ValueError(f"{name} must be positive, got {val_int}")
if max_val is not None and val_int > max_val:
raise ValueError(f"{name} must be <= {max_val}, got {val_int}")
return val_int
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid {name}: {value}") from e
def sanitize_callsign(callsign: str | None) -> str:
"""Sanitize aircraft callsign for display."""
if not callsign:
return ''
# Only allow alphanumeric, dash, and space
return re.sub(r'[^A-Za-z0-9\- ]', '', str(callsign))[:10]
def sanitize_ssid(ssid: str | None) -> str:
"""Sanitize WiFi SSID for display."""
if not ssid:
return ''
# Escape HTML and limit length
return escape_html(str(ssid)[:64])
def sanitize_device_name(name: str | None) -> str:
"""Sanitize Bluetooth device name for display."""
if not name:
return ''
# Escape HTML and limit length
return escape_html(str(name)[:64])