mirror of
https://github.com/smittix/intercept.git
synced 2026-06-20 03:14:21 -07:00
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:
+29
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
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])
|
||||
Reference in New Issue
Block a user