Fix weather satellite decoder security, architecture, and race conditions

Security: replace path traversal-vulnerable str().startswith() with
is_relative_to(), anchor path checks to app root, strip filesystem
paths from error responses, add decoder-level path validation.

Architecture: use safe_terminate/register_process for subprocess
lifecycle, replace custom SSE generator with sse_stream(), use
centralized validate_* functions, remove unused app.py declarations.

Bugs: add thread-safe singleton locks, protect _images list across
threads, move blocking process.wait() to async daemon thread, fix
timezone handling for tz-aware datetimes, use full path for image
deduplication, guard TLE auto-refresh during tests, validate
scheduler parameters to avoid 500 errors.

Docker: pin SatDump to v1.2.2 and slowrx to ca6d7012, document
INTERCEPT_IMAGE fallback pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mitch Ross
2026-02-08 21:29:45 -05:00
parent 94ee22fdd4
commit 54c849ab60
7 changed files with 124 additions and 110 deletions

View File

@@ -136,16 +136,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# Build slowrx (SSTV decoder)
# Build slowrx (SSTV decoder) — pinned to known-good commit
&& cd /tmp \
&& git clone --depth 1 https://github.com/windytan/slowrx.git \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx \
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT)
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
&& cd /tmp \
&& git clone --depth 1 https://github.com/SatDump/SatDump.git \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \

4
app.py
View File

@@ -182,10 +182,6 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# Weather Satellite (NOAA/Meteor)
weather_sat_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
weather_sat_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)

View File

@@ -12,6 +12,8 @@
services:
intercept:
# When INTERCEPT_IMAGE is set, use that pre-built image; when empty/unset,
# the empty string causes Docker Compose to fall through to the build: directive.
image: ${INTERCEPT_IMAGE:-}
build: .
container_name: intercept
@@ -61,6 +63,7 @@ services:
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
# Same image/build fallback pattern as above
image: ${INTERCEPT_IMAGE:-}
build: .
container_name: intercept-history

View File

@@ -31,6 +31,7 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES)
# Auto-refresh TLEs from CelesTrak on startup (non-blocking)
import os
import threading
def _auto_refresh_tle():
@@ -42,7 +43,9 @@ def _auto_refresh_tle():
logger.warning(f"Auto TLE refresh failed: {e}")
# Delay import — refresh_tle_data is defined later in this module
threading.Timer(2.0, _auto_refresh_tle).start()
# Guard to avoid firing during tests
if not os.environ.get('TESTING'):
threading.Timer(2.0, _auto_refresh_tle).start()
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:

View File

@@ -7,13 +7,12 @@ from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.sse import sse_stream
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
from utils.weather_sat import (
get_weather_sat_decoder,
is_weather_sat_available,
@@ -116,28 +115,14 @@ def start_capture():
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
}), 400
# Validate device index
device_index = data.get('device', 0)
# Validate device index and gain
try:
device_index = int(device_index)
if not (0 <= device_index <= 255):
raise ValueError
except (TypeError, ValueError):
device_index = validate_device_index(data.get('device', 0))
gain = validate_gain(data.get('gain', 40.0))
except ValueError as e:
return jsonify({
'status': 'error',
'message': 'Invalid device index (0-255)'
}), 400
# Validate gain
gain = data.get('gain', 40.0)
try:
gain = float(gain)
if not (0 <= gain <= 50):
raise ValueError
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid gain (0-50 dB)'
'message': str(e)
}), 400
bias_t = bool(data.get('bias_t', False))
@@ -252,11 +237,11 @@ def test_decode():
from pathlib import Path
input_path = Path(input_file)
# Security: restrict to data directory
allowed_base = Path('data').resolve()
# Security: restrict to data directory (anchored to app root, not CWD)
allowed_base = Path(__file__).resolve().parent.parent / 'data'
try:
resolved = input_path.resolve()
if not str(resolved).startswith(str(allowed_base)):
if not resolved.is_relative_to(allowed_base):
return jsonify({
'status': 'error',
'message': 'input_file must be under the data/ directory'
@@ -268,9 +253,10 @@ def test_decode():
}), 400
if not input_path.is_file():
logger.warning(f"Test-decode file not found: {input_file}")
return jsonify({
'status': 'error',
'message': f'File not found: {input_file}'
'message': 'File not found'
}), 404
# Validate sample rate
@@ -440,22 +426,7 @@ def stream_progress():
Returns:
SSE stream (text/event-stream)
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
progress = _weather_sat_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
@@ -477,26 +448,26 @@ def get_passes():
Returns:
JSON with upcoming passes for all weather satellites.
"""
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 24, type=int)
min_elevation = request.args.get('min_elevation', 15, type=float)
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
if lat is None or lon is None:
raw_lat = request.args.get('latitude')
raw_lon = request.args.get('longitude')
if raw_lat is None or raw_lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
if not (-90 <= lat <= 90):
return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400
if not (-180 <= lon <= 180):
return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400
try:
lat = validate_latitude(raw_lat)
lon = validate_longitude(raw_lon)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
hours = max(1, min(hours, 72))
min_elevation = max(0, min(min_elevation, 90))
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
try:
from utils.weather_sat_predict import predict_passes
@@ -529,7 +500,7 @@ def get_passes():
logger.error(f"Error predicting passes: {e}")
return jsonify({
'status': 'error',
'message': str(e)
'message': 'Pass prediction failed'
}), 500
@@ -571,24 +542,22 @@ def enable_schedule():
data = request.get_json(silent=True) or {}
lat = data.get('latitude')
lon = data.get('longitude')
if lat is None or lon is None:
if data.get('latitude') is None or data.get('longitude') is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude required'
}), 400
try:
lat = float(lat)
lon = float(lon)
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
raise ValueError
except (TypeError, ValueError):
lat = validate_latitude(data.get('latitude'))
lon = validate_longitude(data.get('longitude'))
min_elev = validate_elevation(data.get('min_elevation', 15))
device = validate_device_index(data.get('device', 0))
gain_val = validate_gain(data.get('gain', 40.0))
except ValueError as e:
return jsonify({
'status': 'error',
'message': 'Invalid coordinates'
'message': str(e)
}), 400
scheduler = get_weather_sat_scheduler()
@@ -597,9 +566,9 @@ def enable_schedule():
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=float(data.get('min_elevation', 15)),
device=int(data.get('device', 0)),
gain=float(data.get('gain', 40.0)),
min_elevation=min_elev,
device=device,
gain=gain_val,
bias_t=bool(data.get('bias_t', False)),
)

View File

@@ -29,6 +29,7 @@ from pathlib import Path
from typing import Callable
from utils.logging import get_logger
from utils.process import register_process, safe_terminate
logger = get_logger('intercept.weather_sat')
@@ -145,6 +146,7 @@ class WeatherSatDecoder:
self._process: subprocess.Popen | None = None
self._running = False
self._lock = threading.Lock()
self._images_lock = threading.Lock()
self._callback: Callable[[CaptureProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
self._images: list[WeatherSatImage] = []
@@ -243,11 +245,30 @@ class WeatherSatDecoder:
return False
input_path = Path(input_file)
# Security: restrict to data directory
allowed_base = Path(__file__).resolve().parent.parent / 'data'
try:
resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base):
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
self._emit_progress(CaptureProgress(
status='error',
message='Input file must be under the data/ directory'
))
return False
except (OSError, ValueError):
self._emit_progress(CaptureProgress(
status='error',
message='Invalid file path'
))
return False
if not input_path.is_file():
logger.error(f"Input file not found: {input_file}")
self._emit_progress(CaptureProgress(
status='error',
message=f'Input file not found: {input_file}'
message='Input file not found'
))
return False
@@ -417,12 +438,17 @@ class WeatherSatDecoder:
stdin=subprocess.DEVNULL,
close_fds=True,
)
register_process(self._process)
os.close(slave_fd) # parent doesn't need the slave side
# Check for early exit (SatDump errors out immediately)
try:
retcode = self._process.wait(timeout=3)
# Process already died — read whatever output it produced
# Check for early exit asynchronously (avoid blocking /start for 3s)
def _check_early_exit():
"""Poll once after 3s; if SatDump died, emit an error event."""
time.sleep(3)
process = self._process
if process is None or process.poll() is None:
return # still running or already cleaned up
retcode = process.returncode
output = b''
try:
while True:
@@ -435,8 +461,6 @@ class WeatherSatDecoder:
output += chunk
except OSError:
pass
os.close(master_fd)
self._pty_master_fd = None
output_str = output.decode('utf-8', errors='replace')
error_msg = f"SatDump exited immediately (code {retcode})"
if output_str:
@@ -445,11 +469,17 @@ class WeatherSatDecoder:
error_msg = line.strip()
break
logger.error(f"SatDump output:\n{output_str}")
self._process = None
raise RuntimeError(error_msg)
except subprocess.TimeoutExpired:
# Good — process is still running after 3 seconds
pass
self._emit_progress(CaptureProgress(
status='error',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=error_msg,
log_type='error',
capture_phase='error',
))
threading.Thread(target=_check_early_exit, daemon=True).start()
# Start reader thread to monitor output
self._reader_thread = threading.Thread(
@@ -508,6 +538,7 @@ class WeatherSatDecoder:
stdin=subprocess.DEVNULL,
close_fds=True,
)
register_process(self._process)
os.close(slave_fd) # parent doesn't need the slave side
# For offline mode, don't check for early exit — file decoding
@@ -782,7 +813,8 @@ class WeatherSatDecoder:
# Recursively scan for image files
for ext in ('*.png', '*.jpg', '*.jpeg'):
for filepath in self._capture_output_dir.rglob(ext):
if filepath.name in known_files:
file_key = str(filepath)
if file_key in known_files:
continue
# Skip tiny files (likely incomplete)
@@ -793,7 +825,7 @@ class WeatherSatDecoder:
except OSError:
continue
known_files.add(filepath.name)
known_files.add(file_key)
# Determine product type from filename/path
product = self._parse_product_name(filepath)
@@ -817,7 +849,8 @@ class WeatherSatDecoder:
size_bytes=stat.st_size,
product=product,
)
self._images.append(image)
with self._images_lock:
self._images.append(image)
logger.info(f"New weather satellite image: {serve_name} ({product})")
self._emit_progress(CaptureProgress(
@@ -877,16 +910,7 @@ class WeatherSatDecoder:
self._pty_master_fd = None
if self._process:
try:
self._process.terminate()
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
except Exception:
try:
self._process.kill()
except Exception:
pass
safe_terminate(self._process)
self._process = None
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
@@ -894,11 +918,15 @@ class WeatherSatDecoder:
def get_images(self) -> list[WeatherSatImage]:
"""Get list of decoded images."""
self._scan_images()
return list(self._images)
with self._images_lock:
self._scan_images()
return list(self._images)
def _scan_images(self) -> None:
"""Scan output directory for images not yet tracked."""
"""Scan output directory for images not yet tracked.
Must be called with self._images_lock held.
"""
known_filenames = {img.filename for img in self._images}
for ext in ('*.png', '*.jpg', '*.jpeg'):
@@ -940,7 +968,8 @@ class WeatherSatDecoder:
if filepath.exists():
try:
filepath.unlink()
self._images = [img for img in self._images if img.filename != filename]
with self._images_lock:
self._images = [img for img in self._images if img.filename != filename]
return True
except OSError as e:
logger.error(f"Failed to delete image {filename}: {e}")
@@ -956,7 +985,8 @@ class WeatherSatDecoder:
count += 1
except OSError:
pass
self._images.clear()
with self._images_lock:
self._images.clear()
return count
def _emit_progress(self, progress: CaptureProgress) -> None:
@@ -987,13 +1017,16 @@ class WeatherSatDecoder:
# Global decoder instance
_decoder: WeatherSatDecoder | None = None
_decoder_lock = threading.Lock()
def get_weather_sat_decoder() -> WeatherSatDecoder:
"""Get or create the global weather satellite decoder instance."""
global _decoder
if _decoder is None:
_decoder = WeatherSatDecoder()
with _decoder_lock:
if _decoder is None:
_decoder = WeatherSatDecoder()
return _decoder

View File

@@ -49,11 +49,17 @@ class ScheduledPass:
@property
def start_dt(self) -> datetime:
return datetime.fromisoformat(self.start_time).replace(tzinfo=timezone.utc)
dt = datetime.fromisoformat(self.start_time)
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@property
def end_dt(self) -> datetime:
return datetime.fromisoformat(self.end_time).replace(tzinfo=timezone.utc)
dt = datetime.fromisoformat(self.end_time)
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_dict(self) -> dict[str, Any]:
return {
@@ -375,11 +381,14 @@ class WeatherSatScheduler:
# Singleton
_scheduler: WeatherSatScheduler | None = None
_scheduler_lock = threading.Lock()
def get_weather_sat_scheduler() -> WeatherSatScheduler:
"""Get or create the global weather satellite scheduler instance."""
global _scheduler
if _scheduler is None:
_scheduler = WeatherSatScheduler()
with _scheduler_lock:
if _scheduler is None:
_scheduler = WeatherSatScheduler()
return _scheduler