mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -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
4
app.py
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user