diff --git a/README.md b/README.md index d4f9ee3..380630b 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2). - [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode - [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup - [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions +- [Security](docs/SECURITY.md) - Network security and best practices --- diff --git a/app.py b/app.py index 27fcdc9..eb069bc 100644 --- a/app.py +++ b/app.py @@ -45,6 +45,30 @@ _app_start_time = _time.time() # Create Flask app app = Flask(__name__) +# Disable Werkzeug debugger PIN (not needed for local development tool) +os.environ['WERKZEUG_DEBUG_PIN'] = 'off' + + +# ============================================ +# SECURITY HEADERS +# ============================================ + +@app.after_request +def add_security_headers(response): + """Add security headers to all responses.""" + # Prevent MIME type sniffing + response.headers['X-Content-Type-Options'] = 'nosniff' + # Prevent clickjacking + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + # Enable XSS filter + response.headers['X-XSS-Protection'] = '1; mode=block' + # Referrer policy + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + # Permissions policy (disable unnecessary features) + response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()' + return response + + # ============================================ # GLOBAL PROCESS MANAGEMENT # ============================================ diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..480e9bf --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,89 @@ +# Security Considerations + +INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices. + +## Network Binding + +By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications: + +### Recommendations + +1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050: + ```bash + # Linux (iptables) + sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT + sudo iptables -A INPUT -p tcp --dport 5050 -j DROP + + # macOS (pf) + echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef - + ``` + +2. **Bind to Localhost**: For local-only access, set the host environment variable: + ```bash + export INTERCEPT_HOST=127.0.0.1 + python intercept.py + ``` + +3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism. + +## Authentication + +INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks: + +1. Use a reverse proxy (nginx, Caddy) with authentication +2. Use a VPN to access your home network +3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server` + +## Security Headers + +INTERCEPT includes the following security headers on all responses: + +| Header | Value | Purpose | +|--------|-------|---------| +| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing | +| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking | +| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information | +| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features | + +## Input Validation + +All user inputs are validated before use: + +- **Network interface names**: Validated against strict regex pattern +- **Bluetooth interface names**: Must match `hciX` format +- **MAC addresses**: Validated format +- **Frequencies**: Validated range and format +- **File paths**: Protected against directory traversal +- **HTML output**: All user-provided content is escaped + +## Subprocess Execution + +INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures: + +- **No shell execution**: All subprocess calls use list arguments, not shell strings +- **Input validation**: All user-provided arguments are validated before use +- **Process isolation**: Each tool runs in its own process with limited permissions + +## Debug Mode + +Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`: + +- The Werkzeug debugger PIN is disabled (not needed for local tool) +- Additional logging is enabled +- Stack traces are shown on errors + +**Never run in debug mode on untrusted networks.** + +## Reporting Security Issues + +If you discover a security vulnerability, please report it by: + +1. Opening a GitHub issue (for non-sensitive issues) +2. Emailing the maintainer directly (for sensitive issues) + +Please include: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) diff --git a/routes/adsb.py b/routes/adsb.py index 230c40a..f38ac59 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -381,9 +381,11 @@ def start_adsb(): builder = SDRFactory.get_builder(sdr_type) # Build ADS-B decoder command + bias_t = data.get('bias_t', False) cmd = builder.build_adsb_command( device=sdr_device, - gain=float(gain) + gain=float(gain), + bias_t=bias_t ) # For RTL-SDR, ensure we use the found dump1090 path diff --git a/routes/bluetooth.py b/routes/bluetooth.py index 1c8eb21..50a659b 100644 --- a/routes/bluetooth.py +++ b/routes/bluetooth.py @@ -21,6 +21,7 @@ import app as app_module from utils.dependencies import check_tool from utils.logging import bluetooth_logger as logger from utils.sse import format_sse +from utils.validation import validate_bluetooth_interface from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER from utils.constants import ( @@ -304,9 +305,14 @@ def start_bt_scan(): data = request.json scan_mode = data.get('mode', 'hcitool') - interface = data.get('interface', 'hci0') scan_ble = data.get('scan_ble', True) + # Validate Bluetooth interface name + try: + interface = validate_bluetooth_interface(data.get('interface', 'hci0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + app_module.bt_interface = interface app_module.bt_devices = {} @@ -388,7 +394,12 @@ def stop_bt_scan(): def reset_bt_adapter(): """Reset Bluetooth adapter.""" data = request.json - interface = data.get('interface', 'hci0') + + # Validate Bluetooth interface name + try: + interface = validate_bluetooth_interface(data.get('interface', 'hci0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 with app_module.bt_lock: if app_module.bt_process: diff --git a/routes/listening_post.py b/routes/listening_post.py index bddb38a..9b33b33 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -54,6 +54,7 @@ scanner_config = { 'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning) 'device': 0, 'gain': 40, + 'bias_t': False, # Bias-T power for external LNA } # Activity log @@ -177,6 +178,9 @@ def scanner_loop(): '-g', str(scanner_config['gain']), '-d', str(scanner_config['device']), ] + # Add bias-t flag if enabled (for external LNA power) + if scanner_config.get('bias_t', False): + rtl_cmd.append('-T') try: # Start rtl_fm @@ -365,6 +369,9 @@ def _start_audio_stream(frequency: float, modulation: str): '-d', str(scanner_config['device']), '-l', str(scanner_config['squelch']), ] + # Add bias-t flag if enabled (for external LNA power) + if scanner_config.get('bias_t', False): + rtl_cmd.append('-T') encoder_cmd = [ ffmpeg_path, @@ -497,6 +504,7 @@ def start_scanner() -> Response: scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) scanner_config['device'] = int(data.get('device', 0)) scanner_config['gain'] = int(data.get('gain', 40)) + scanner_config['bias_t'] = bool(data.get('bias_t', False)) except (ValueError, TypeError) as e: return jsonify({ 'status': 'error', diff --git a/routes/pager.py b/routes/pager.py index d68acc1..0c0fe3a 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -233,6 +233,7 @@ def start_decoding() -> Response: builder = SDRFactory.get_builder(sdr_device.sdr_type) # Build FM demodulation command + bias_t = data.get('bias_t', False) rtl_cmd = builder.build_fm_demod_command( device=sdr_device, frequency_mhz=freq, @@ -240,7 +241,8 @@ def start_decoding() -> Response: gain=float(gain) if gain and gain != '0' else None, ppm=int(ppm) if ppm and ppm != '0' else None, modulation='fm', - squelch=squelch if squelch and squelch != 0 else None + squelch=squelch if squelch and squelch != 0 else None, + bias_t=bias_t ) multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-'] diff --git a/routes/sensor.py b/routes/sensor.py index aa21a53..ff4ca06 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -114,11 +114,13 @@ def start_sensor() -> Response: builder = SDRFactory.get_builder(sdr_device.sdr_type) # Build ISM band decoder command + bias_t = data.get('bias_t', False) cmd = builder.build_ism_command( device=sdr_device, frequency_mhz=freq, gain=float(gain) if gain and gain != 0 else None, - ppm=int(ppm) if ppm and ppm != 0 else None + ppm=int(ppm) if ppm and ppm != 0 else None, + bias_t=bias_t ) full_cmd = ' '.join(cmd) diff --git a/routes/wifi.py b/routes/wifi.py index ac6e557..911ec1c 100644 --- a/routes/wifi.py +++ b/routes/wifi.py @@ -19,7 +19,7 @@ import app as app_module from utils.dependencies import check_tool, get_tool_path from utils.logging import wifi_logger as logger from utils.process import is_valid_mac, is_valid_channel -from utils.validation import validate_wifi_channel, validate_mac_address +from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface from utils.sse import format_sse from data.oui import get_manufacturer from utils.constants import ( @@ -303,11 +303,13 @@ def get_wifi_interfaces(): def toggle_monitor_mode(): """Enable or disable monitor mode on an interface.""" data = request.json - interface = data.get('interface') action = data.get('action', 'start') - if not interface: - return jsonify({'status': 'error', 'message': 'No interface specified'}) + # Validate interface name to prevent command injection + try: + interface = validate_network_interface(data.get('interface')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 if action == 'start': if check_tool('airmon-ng'): @@ -458,10 +460,19 @@ def start_wifi_scan(): return jsonify({'status': 'error', 'message': 'Scan already running'}) data = request.json - interface = data.get('interface') or app_module.wifi_monitor_interface channel = data.get('channel') band = data.get('band', 'abg') + # Use provided interface or fall back to stored monitor interface + interface = data.get('interface') + if interface: + try: + interface = validate_network_interface(interface) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + else: + interface = app_module.wifi_monitor_interface + if not interface: return jsonify({'status': 'error', 'message': 'No monitor interface available.'}) @@ -557,7 +568,16 @@ def send_deauth(): target_bssid = data.get('bssid') target_client = data.get('client', 'FF:FF:FF:FF:FF:FF') count = data.get('count', 5) - interface = data.get('interface') or app_module.wifi_monitor_interface + + # Validate interface + interface = data.get('interface') + if interface: + try: + interface = validate_network_interface(interface) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + else: + interface = app_module.wifi_monitor_interface if not target_bssid: return jsonify({'status': 'error', 'message': 'Target BSSID required'}) @@ -612,7 +632,16 @@ def capture_handshake(): data = request.json target_bssid = data.get('bssid') channel = data.get('channel') - interface = data.get('interface') or app_module.wifi_monitor_interface + + # Validate interface + interface = data.get('interface') + if interface: + try: + interface = validate_network_interface(interface) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + else: + interface = app_module.wifi_monitor_interface if not target_bssid or not channel: return jsonify({'status': 'error', 'message': 'BSSID and channel required'}) @@ -701,7 +730,16 @@ def capture_pmkid(): data = request.json target_bssid = data.get('bssid') channel = data.get('channel') - interface = data.get('interface') or app_module.wifi_monitor_interface + + # Validate interface + interface = data.get('interface') + if interface: + try: + interface = validate_network_interface(interface) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + else: + interface = app_module.wifi_monitor_interface if not target_bssid: return jsonify({'status': 'error', 'message': 'BSSID required'}) diff --git a/templates/index.html b/templates/index.html index 6a10fbc..b1336fb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -330,6 +330,17 @@ + +