diff --git a/tools/SharpEmu.DebuggerFrontend/README.md b/tools/SharpEmu.DebuggerFrontend/README.md index f0af4fad..d3fc57fd 100644 --- a/tools/SharpEmu.DebuggerFrontend/README.md +++ b/tools/SharpEmu.DebuggerFrontend/README.md @@ -48,8 +48,12 @@ Useful options: ```text --debug-host HOST Debug server host (default 127.0.0.1) --debug-port PORT Debug server port (default 5714) ---listen ADDRESS Web UI bind address (default 127.0.0.1) +--listen ADDRESS Loopback Web UI bind address (default 127.0.0.1) --ui-port PORT Web UI port; 0 chooses a free port (default 8765) +--emulator-path PATH + Trusted local SharpEmu executable override +--allow-remote-debugger + Allow connecting the bridge to a non-loopback debugger --no-connect Do not connect to SharpEmu automatically --no-browser Do not open a browser automatically --verbose Print HTTP request logs @@ -80,8 +84,16 @@ python3 -m unittest discover -s tools/SharpEmu.DebuggerFrontend/tests -v node --check tools/SharpEmu.DebuggerFrontend/web/app.js ``` -The HTTP service binds to loopback by default and has no authentication. Only -use a non-loopback `--listen` address on a trusted network. +The HTTP service only accepts `localhost`, `127.0.0.1`, or `::1` listeners and +Host headers. Loopback is not treated as authentication because unrelated web +pages can still send requests to local services. Every API request requires an +ephemeral token, while state-changing requests additionally require same-origin +browser provenance and JSON content. The emulator executable is resolved +locally; `/api/launch` cannot override it. Use `--emulator-path` when a +non-default local build is required. Remote debugger connections are disabled +unless explicitly enabled with `--allow-remote-debugger`; this does not expose +the HTTP frontend. The debugger protocol itself has no transport authentication, +so only enable remote access over a trusted network or authenticated tunnel. On Linux, the Browse button uses `zenity` or `kdialog`. A full path can always be entered manually. Closing the frontend also stops the emulator process it diff --git a/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py b/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py index 3eea751a..02f3a085 100755 --- a/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py +++ b/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py @@ -10,22 +10,26 @@ import argparse import copy from collections import deque import errno +import hmac from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress import json import os import platform import queue import re +import secrets from pathlib import Path import shutil import signal import socket import subprocess +import sys import threading import time from typing import Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urlparse, urlsplit from urllib.error import URLError from urllib.request import Request, urlopen import webbrowser @@ -37,6 +41,9 @@ WEB_ROOT = APP_ROOT / "web" MAX_REQUEST_BYTES = 1024 * 1024 MAX_JOURNAL_MESSAGES = 1000 APPLICATION_ID = "sharpemu-debugger-frontend" +CSRF_HEADER = "X-SharpEmu-CSRF-Token" +CSRF_TOKEN_PLACEHOLDER = b"__SHARPEMU_CSRF_TOKEN__" +ALLOWED_HTTP_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) KNOWN_WAIT_IMPORTS: dict[str, tuple[str, str, str]] = { "9UK1vLZQft4": ("libKernel", "scePthreadMutexLock", "mutex"), @@ -53,6 +60,29 @@ KNOWN_WAIT_IMPORTS: dict[str, tuple[str, str, str]] = { } +def _normalize_loopback_host(host: str) -> str | None: + """Returns a stable loopback host name, rejecting DNS-rebindable names.""" + + value = host.strip().lower() + if value.startswith("[") and value.endswith("]"): + value = value[1:-1] + value = value.rstrip(".") + if value == "localhost": + return value + try: + address = ipaddress.ip_address(value) + except ValueError: + return None + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + address = address.ipv4_mapped + return address.compressed if address.is_loopback else None + + +def _normalize_http_host(host: str) -> str | None: + normalized = _normalize_loopback_host(host) + return normalized if normalized in ALLOWED_HTTP_HOSTS else None + + def analyze_debug_stop(stop: dict[str, Any]) -> dict[str, Any] | None: """Turns structured or legacy stall evidence into actionable guidance.""" @@ -238,6 +268,10 @@ class BridgeError(RuntimeError): """Raised when the debugger bridge cannot complete an operation.""" +class RequestValidationError(RuntimeError): + """Raised when an HTTP request violates the frontend API contract.""" + + class EventJournal: """A bounded, cursor-addressable activity stream for the browser.""" @@ -268,8 +302,9 @@ class EventJournal: class EmulatorProcessManager: """Launches and supervises the one emulator process owned by the UI.""" - def __init__(self, journal: EventJournal) -> None: + def __init__(self, journal: EventJournal, emulator_path: str | None = None) -> None: self.journal = journal + self._configured_emulator_path = emulator_path self._lock = threading.RLock() self._process: subprocess.Popen[str] | None = None self._reader_thread: threading.Thread | None = None @@ -282,7 +317,6 @@ class EmulatorProcessManager: self, eboot_path: str, debug_port: int, - emulator_path: str | None = None, ) -> dict[str, Any]: eboot = Path(eboot_path).expanduser().resolve() if not eboot.is_file(): @@ -292,7 +326,7 @@ class EmulatorProcessManager: if self._port_is_open("127.0.0.1", debug_port): raise BridgeError(f"Port {debug_port} is already in use. Stop the existing debugger or choose another port.") - executable = self.resolve_emulator(emulator_path) + executable = self.resolve_emulator() with self._lock: self._refresh_process_locked() if self._process is not None and self._process.poll() is None: @@ -305,7 +339,7 @@ class EmulatorProcessManager: environment["PATH"] = f"{local_dotnet}{os.pathsep}{environment.get('PATH', '')}" command = [ - str(executable), + *([sys.executable, str(executable)] if executable.suffix.lower() == ".py" else [str(executable)]), f"--debug-server=127.0.0.1:{debug_port}", str(eboot), ] @@ -432,9 +466,9 @@ class EmulatorProcessManager: selected = result.stdout.strip() return str(Path(selected).expanduser().resolve()) if selected else None - def resolve_emulator(self, explicit_path: str | None = None) -> Path: - if explicit_path: - candidate = Path(explicit_path).expanduser().resolve() + def resolve_emulator(self) -> Path: + if self._configured_emulator_path: + candidate = Path(self._configured_emulator_path).expanduser().resolve() else: system = platform.system().lower() machine = platform.machine().lower() @@ -533,9 +567,15 @@ class EmulatorProcessManager: class DebuggerBridge: """Owns one debugger socket and serializes request/reply traffic.""" - def __init__(self, default_host: str = "127.0.0.1", default_port: int = 5714) -> None: + def __init__( + self, + default_host: str = "127.0.0.1", + default_port: int = 5714, + allow_remote: bool = False, + ) -> None: self.default_host = default_host self.default_port = default_port + self.allow_remote = allow_remote self.journal = EventJournal() self._state_lock = threading.RLock() @@ -564,18 +604,28 @@ class DebuggerBridge: host = host.strip() if not host: raise BridgeError("A debugger host is required.") + socket_host = host[1:-1] if host.startswith("[") and host.endswith("]") else host + if not self.allow_remote and _normalize_loopback_host(socket_host) is None: + raise BridgeError( + "Remote debugger connections are disabled. " + "Restart with --allow-remote-debugger to opt in." + ) if port < 1 or port > 65535: raise BridgeError("The debugger port must be between 1 and 65535.") self.disconnect(log=False) self._drain_responses() try: - client = socket.create_connection((host, port), timeout=timeout) + client = socket.create_connection((socket_host, port), timeout=timeout) + peer_host = str(client.getpeername()[0]) + if not self.allow_remote and _normalize_loopback_host(peer_host) is None: + client.close() + raise BridgeError("The debugger host resolved outside loopback.") client.settimeout(None) client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) reader = client.makefile("r", encoding="utf-8", newline="\n") except OSError as exc: - message = f"Could not connect to {host}:{port}: {exc}" + message = f"Could not connect to {socket_host}:{port}: {exc}" self.journal.append("error", message) raise BridgeError(message) from exc @@ -584,7 +634,7 @@ class DebuggerBridge: generation = self._generation self._socket = client self._connected = True - self._endpoint = f"{host}:{port}" + self._endpoint = f"{socket_host}:{port}" self._protocol = None self._target_state = "Connecting" self._last_stop = None @@ -592,7 +642,7 @@ class DebuggerBridge: self._breakpoints = [] self._hello_event = threading.Event() - self.journal.append("system", f"Connected to {host}:{port}") + self.journal.append("system", f"Connected to {socket_host}:{port}") thread = threading.Thread( target=self._read_loop, args=(generation, client, reader), @@ -605,7 +655,7 @@ class DebuggerBridge: self._hello_event.wait(timeout=min(timeout, 1.5)) with self._state_lock: if generation != self._generation or not self._connected: - raise BridgeError(f"The debugger at {host}:{port} closed the connection.") + raise BridgeError(f"The debugger at {socket_host}:{port} closed the connection.") def disconnect(self, reason: str = "Disconnected", log: bool = True) -> None: with self._lifecycle_lock: @@ -715,9 +765,12 @@ class DebuggerBridge: reason = "Debugger closed the connection." try: while True: - line = reader.readline() + line = reader.readline(MAX_REQUEST_BYTES + 1) if line == "": break + if len(line) > MAX_REQUEST_BYTES: + reason = "Debugger sent an oversized protocol message." + break line = line.strip() if not line: continue @@ -825,9 +878,16 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): bridge: DebuggerBridge process_manager: EmulatorProcessManager verbose = False + server_version = "SharpEmuDebuggerFrontend" + sys_version = "" def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if self._validated_request_authority() is None: + return parsed = urlparse(self.path) + if parsed.path.startswith("/api/") and not self._has_valid_csrf_token(): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Missing or invalid API token."}) + return if parsed.path == "/api/snapshot": values = parse_qs(parsed.query) try: @@ -861,20 +921,17 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): except OSError: self.send_error(HTTPStatus.NOT_FOUND) return + if file_name == "index.html": + body = body.replace(CSRF_TOKEN_PLACEHOLDER, self.server.csrf_token.encode("ascii")) self.send_response(HTTPStatus.OK) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.send_header("X-Content-Type-Options", "nosniff") - self.send_header( - "Content-Security-Policy", - "default-src 'self'; connect-src 'self'; img-src 'self' data:; " - "script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'", - ) self.end_headers() self.wfile.write(body) def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if self._validated_post_authority() is None: + return parsed = urlparse(self.path) try: body = self._read_json_body() @@ -898,12 +955,14 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): if not isinstance(eboot_path, str) or not eboot_path.strip(): raise BridgeError("Choose an eboot file before launching.") debug_port = int(body.get("debugPort", self.bridge.default_port)) - emulator_path = body.get("emulatorPath") - if emulator_path is not None and not isinstance(emulator_path, str): - raise BridgeError("'emulatorPath' must be a string.") + if "emulatorPath" in body: + raise RequestValidationError( + "'emulatorPath' cannot be supplied over HTTP. " + "Configure it locally with --emulator-path." + ) self.bridge.disconnect(log=False) try: - self.process_manager.launch(eboot_path, debug_port, emulator_path) + self.process_manager.launch(eboot_path, debug_port) self.process_manager.wait_for_debugger(debug_port) self.bridge.connect("127.0.0.1", debug_port) self._prime_snapshot() @@ -934,11 +993,18 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): self._send_json(HTTPStatus.OK, {"response": response}) return self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown API endpoint."}) - except (BridgeError, ValueError, TypeError) as exc: + except RequestValidationError as exc: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + except BridgeError as exc: self._send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)}) - except json.JSONDecodeError: + except (json.JSONDecodeError, UnicodeDecodeError, ValueError, TypeError): self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Request body is not valid JSON."}) + def do_OPTIONS(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if self._validated_request_authority() is None: + return + self._send_json(HTTPStatus.METHOD_NOT_ALLOWED, {"error": "CORS requests are not supported."}) + def _prime_snapshot(self) -> None: try: self.bridge.request({"command": "status"}) @@ -955,17 +1021,122 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): self.server.shutdown() self.server.server_close() - def _read_json_body(self) -> dict[str, Any]: + def _validated_request_authority(self) -> tuple[str, int] | None: + client_host = str(self.client_address[0]) if self.client_address else "" + if _normalize_loopback_host(client_host) is None: + self._send_json(HTTPStatus.FORBIDDEN, {"error": "The frontend only accepts loopback clients."}) + return None + + host_values = self.headers.get_all("Host", []) + if len(host_values) != 1: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Exactly one Host header is required."}) + return None + authority = self._parse_host_authority(host_values[0]) + expected_port = int(self.server.server_address[1]) + if authority is None or authority[1] != expected_port: + self._send_json(HTTPStatus.FORBIDDEN, {"error": "The Host header is not allowed."}) + return None + + fetch_site = self.headers.get("Sec-Fetch-Site", "").strip().lower() + allowed_fetch_sites = {"", "same-origin"} + if self.command == "GET": + allowed_fetch_sites.add("none") + if fetch_site not in allowed_fetch_sites: + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Cross-origin requests are not allowed."}) + return None + return authority + + def _validated_post_authority(self) -> tuple[str, int] | None: + authority = self._validated_request_authority() + if authority is None: + return None + + if not self._has_valid_csrf_token(): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Missing or invalid CSRF token."}) + return None + + origin_values = self.headers.get_all("Origin", []) + referer_values = self.headers.get_all("Referer", []) + if not origin_values and not referer_values: + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Origin or Referer is required."}) + return None + if origin_values and ( + len(origin_values) != 1 or self._parse_origin_authority(origin_values[0]) != authority + ): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "The request Origin is not allowed."}) + return None + + if referer_values and ( + len(referer_values) != 1 or self._parse_origin_authority(referer_values[0]) != authority + ): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "The request Referer is not allowed."}) + return None + return authority + + def _has_valid_csrf_token(self) -> bool: + token_values = self.headers.get_all(CSRF_HEADER, []) + supplied_token = token_values[0] if len(token_values) == 1 else "" + return bool(supplied_token) and hmac.compare_digest(supplied_token, self.server.csrf_token) + + @staticmethod + def _parse_host_authority(value: str) -> tuple[str, int] | None: try: - length = int(self.headers.get("Content-Length", "0")) + parsed = urlsplit(f"//{value}") + port = parsed.port if parsed.port is not None else 80 + except ValueError: + return None + if ( + not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + ): + return None + host = _normalize_http_host(parsed.hostname) + return (host, port) if host is not None else None + + @staticmethod + def _parse_origin_authority(value: str) -> tuple[str, int] | None: + try: + parsed = urlsplit(value) + port = parsed.port if parsed.port is not None else 80 + except ValueError: + return None + if ( + parsed.scheme.lower() != "http" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + ): + return None + host = _normalize_http_host(parsed.hostname) + return (host, port) if host is not None else None + + def _read_json_body(self) -> dict[str, Any]: + if self.headers.get("Transfer-Encoding") is not None: + raise RequestValidationError("Transfer-Encoding is not supported.") + if self.headers.get_content_type() != "application/json": + raise RequestValidationError("Content-Type must be application/json.") + charset = self.headers.get_content_charset() + if charset is not None and charset.lower() not in {"utf-8", "utf8"}: + raise RequestValidationError("JSON requests must use UTF-8.") + length_values = self.headers.get_all("Content-Length", []) + if len(length_values) != 1: + raise RequestValidationError("Exactly one Content-Length header is required.") + try: + length = int(length_values[0]) except ValueError as exc: - raise BridgeError("Invalid Content-Length header.") from exc + raise RequestValidationError("Invalid Content-Length header.") from exc if length < 0 or length > MAX_REQUEST_BYTES: - raise BridgeError("HTTP request body is too large.") + raise RequestValidationError("HTTP request body is too large.") raw = self.rfile.read(length) + if len(raw) != length: + raise RequestValidationError("HTTP request body ended unexpectedly.") value = json.loads(raw.decode("utf-8") if raw else "{}") if not isinstance(value, dict): - raise BridgeError("Expected a JSON object request body.") + raise RequestValidationError("Expected a JSON object request body.") return value def _send_json(self, status: HTTPStatus, payload: Any) -> None: @@ -974,13 +1145,27 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(body) - except (BrokenPipeError, ConnectionResetError): + except OSError: pass + def end_headers(self) -> None: + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; connect-src 'self'; img-src 'self' data:; " + "script-src 'self'; style-src 'self'; object-src 'none'; form-action 'self'; " + "base-uri 'none'; frame-ancestors 'none'", + ) + super().end_headers() + def log_message(self, format_string: str, *args: Any) -> None: if self.verbose: super().log_message(format_string, *args) @@ -988,7 +1173,17 @@ class FrontendRequestHandler(BaseHTTPRequestHandler): class FrontendHttpServer(ThreadingHTTPServer): daemon_threads = True - allow_reuse_address = True + # SO_REUSEADDR permits duplicate live listeners on Windows instead of only + # allowing quick restart after close. + allow_reuse_address = os.name != "nt" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.csrf_token = secrets.token_urlsafe(32) + super().__init__(*args, **kwargs) + + +class FrontendHttpServerV6(FrontendHttpServer): + address_family = socket.AF_INET6 def create_frontend_server( @@ -998,8 +1193,13 @@ def create_frontend_server( ) -> FrontendHttpServer: """Binds the UI server, replacing an older frontend on the same port.""" + normalized_address = _normalize_http_host(listen_address) + if normalized_address is None: + raise SystemExit("The debugger frontend may only listen on localhost, 127.0.0.1, or ::1.") + listen_address = normalized_address + try: - return FrontendHttpServer((listen_address, port), handler_type) + return _bind_frontend_server(listen_address, port, handler_type) except OSError as exc: if exc.errno != errno.EADDRINUSE or port == 0: raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None @@ -1013,7 +1213,7 @@ def create_frontend_server( deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: try: - return FrontendHttpServer((listen_address, port), handler_type) + return _bind_frontend_server(listen_address, port, handler_type) except OSError as exc: if exc.errno != errno.EADDRINUSE: raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None @@ -1021,23 +1221,54 @@ def create_frontend_server( raise SystemExit(f"The previous SharpEmu frontend did not release port {port} in time.") +def _bind_frontend_server( + listen_address: str, + port: int, + handler_type: type[FrontendRequestHandler], +) -> FrontendHttpServer: + server_type = FrontendHttpServerV6 if listen_address == "::1" else FrontendHttpServer + server = server_type((listen_address, port), handler_type) + if _normalize_http_host(str(server.server_address[0])) is None: + server.server_close() + raise SystemExit("The debugger frontend resolved its listen address outside loopback.") + return server + + def request_existing_frontend_shutdown(listen_address: str, port: int) -> bool: """Stops only a verified SharpEmu frontend listening on the target port.""" - probe_host = "127.0.0.1" if listen_address in {"0.0.0.0", "localhost"} else listen_address + probe_host = "127.0.0.1" if listen_address == "localhost" else listen_address url_host = f"[{probe_host}]" if ":" in probe_host and not probe_host.startswith("[") else probe_host base_url = f"http://{url_host}:{port}" try: - with urlopen(f"{base_url}/api/health", timeout=0.75) as response: + with urlopen(f"{base_url}/", timeout=0.75) as response: + page = response.read(128 * 1024) + except (OSError, URLError): + return False + if b"SharpEmu Debugger" not in page: + return False + + token_match = re.search( + rb' bool: # that legacy instance to exit through its normal Ctrl+C cleanup path. if not isinstance(health, dict) or health.get("ok") is not True: return False - try: - with urlopen(f"{base_url}/", timeout=0.75) as response: - page = response.read(128 * 1024) - except (OSError, URLError): - return False - if b"SharpEmu Debugger" not in page: - return False legacy_pid = find_legacy_frontend_pid(port) if legacy_pid is None: return False @@ -1104,6 +1328,12 @@ def build_argument_parser() -> argparse.ArgumentParser: parser.add_argument("--debug-port", type=int, default=5714, help="SharpEmu debugger port") parser.add_argument("--listen", default="127.0.0.1", help="Frontend HTTP bind address") parser.add_argument("--ui-port", type=int, default=8765, help="Frontend HTTP port (0 chooses a free port)") + parser.add_argument("--emulator-path", help="Trusted local SharpEmu executable path") + parser.add_argument( + "--allow-remote-debugger", + action="store_true", + help="Allow the bridge to connect to a non-loopback debugger host", + ) parser.add_argument("--no-connect", action="store_true", help="Do not connect to SharpEmu on startup") parser.add_argument("--no-browser", action="store_true", help="Do not open the frontend in a browser") parser.add_argument("--verbose", action="store_true", help="Print HTTP request logs") @@ -1117,8 +1347,8 @@ def main(argv: list[str] | None = None) -> int: if args.ui_port < 0 or args.ui_port > 65535: raise SystemExit("--ui-port must be between 0 and 65535") - bridge = DebuggerBridge(args.debug_host, args.debug_port) - process_manager = EmulatorProcessManager(bridge.journal) + bridge = DebuggerBridge(args.debug_host, args.debug_port, allow_remote=args.allow_remote_debugger) + process_manager = EmulatorProcessManager(bridge.journal, args.emulator_path) handler_type = type( "ConfiguredFrontendRequestHandler", (FrontendRequestHandler,), @@ -1126,15 +1356,13 @@ def main(argv: list[str] | None = None) -> int: ) server = create_frontend_server(args.listen, args.ui_port, handler_type) actual_port = server.server_address[1] - browser_host = args.listen if args.listen not in {"0.0.0.0", "::"} else "127.0.0.1" - url = f"http://{browser_host}:{actual_port}/" + browser_host = _normalize_http_host(args.listen) or "127.0.0.1" + url_host = f"[{browser_host}]" if ":" in browser_host else browser_host + url = f"http://{url_host}:{actual_port}/" print(f"SharpEmu Debugger Frontend: {url}") print(f"Debugger endpoint: {args.debug_host}:{args.debug_port}") print("Press Ctrl+C to stop.") - if args.listen not in {"127.0.0.1", "localhost", "::1"}: - print("Warning: the frontend is listening beyond loopback; no authentication is provided.") - if not args.no_connect: def connect_default() -> None: try: diff --git a/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py b/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py index 6c32c192..1827fa86 100644 --- a/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py +++ b/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py @@ -11,6 +11,7 @@ import tempfile import textwrap import threading import unittest +from urllib.error import HTTPError from urllib.request import Request, urlopen @@ -19,6 +20,7 @@ sys.path.insert(0, str(FRONTEND_ROOT)) from debugger_frontend import ( # noqa: E402 BridgeError, + CSRF_HEADER, DebuggerBridge, EmulatorProcessManager, FrontendHttpServer, @@ -145,6 +147,11 @@ class DebuggerBridgeTests(unittest.TestCase): with self.assertRaises(BridgeError): self.bridge.request({}) + def test_remote_debugger_is_rejected_without_explicit_opt_in(self) -> None: + bridge = DebuggerBridge() + with self.assertRaisesRegex(BridgeError, "Remote debugger connections are disabled"): + bridge.connect("example.com", 5714) + def test_journal_cursor_returns_only_new_messages(self) -> None: cursor = self.bridge.snapshot()["cursor"] self.bridge.request({"command": "status"}) @@ -193,6 +200,7 @@ class FrontendHttpTests(unittest.TestCase): ) self.http_server = FrontendHttpServer(("127.0.0.1", 0), handler) self.http_port = self.http_server.server_address[1] + self.csrf_token = self.http_server.csrf_token self.http_thread = threading.Thread(target=self.http_server.serve_forever, daemon=True) self.http_thread.start() @@ -204,16 +212,54 @@ class FrontendHttpTests(unittest.TestCase): self.http_thread.join(timeout=1) self.debugger.close() - def post(self, path: str, payload: dict[str, object]) -> dict[str, object]: + def post( + self, + path: str, + payload: dict[str, object], + *, + include_token: bool = True, + origin: str | None = "same-origin", + referer: str | None = None, + host: str | None = None, + content_type: str = "application/json", + fetch_site: str | None = None, + ) -> dict[str, object]: + headers = {"Content-Type": content_type} + if include_token: + headers[CSRF_HEADER] = self.csrf_token + if origin == "same-origin": + headers["Origin"] = f"http://127.0.0.1:{self.http_port}" + elif origin is not None: + headers["Origin"] = origin + if referer == "same-origin": + headers["Referer"] = f"http://127.0.0.1:{self.http_port}/" + elif referer is not None: + headers["Referer"] = referer + if host is not None: + headers["Host"] = host + if fetch_site is not None: + headers["Sec-Fetch-Site"] = fetch_site request = Request( f"http://127.0.0.1:{self.http_port}{path}", data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, + headers=headers, method="POST", ) - with urlopen(request, timeout=2) as response: + with urlopen(request, timeout=5) as response: return json.load(response) + def assert_post_rejected( + self, + path: str, + payload: dict[str, object], + status: int, + **kwargs: object, + ) -> dict[str, object]: + with self.assertRaises(HTTPError) as caught: + self.post(path, payload, **kwargs) + self.assertEqual(status, caught.exception.code) + return json.load(caught.exception) + def test_api_connect_and_command_round_trip(self) -> None: connected = self.post("/api/connect", {"host": "127.0.0.1", "port": self.debugger.port}) self.assertTrue(connected["connected"]) @@ -226,8 +272,12 @@ class FrontendHttpTests(unittest.TestCase): with urlopen(f"http://127.0.0.1:{self.http_port}/", timeout=2) as response: body = response.read().decode("utf-8") self.assertEqual("text/html; charset=utf-8", response.headers["Content-Type"]) + self.assertEqual("DENY", response.headers["X-Frame-Options"]) + self.assertEqual("same-origin", response.headers["Cross-Origin-Resource-Policy"]) self.assertIn("SharpEmu Debugger", body) self.assertIn('rel="icon" type="image/webp"', body) + self.assertIn(f'content="{self.csrf_token}"', body) + self.assertNotIn("__SHARPEMU_CSRF_TOKEN__", body) with urlopen(f"http://127.0.0.1:{self.http_port}/sharpemu-logo.webp", timeout=2) as response: logo = response.read() @@ -239,7 +289,7 @@ class FrontendHttpTests(unittest.TestCase): root = Path(temporary_directory) eboot = root / "eboot.bin" eboot.write_bytes(b"test") - emulator = root / "fake-sharpemu" + emulator = root / "fake-sharpemu.py" emulator.write_text(textwrap.dedent("""\ #!/usr/bin/env python3 import json @@ -255,29 +305,36 @@ class FrontendHttpTests(unittest.TestCase): print("Fake SharpEmu debug server ready", flush=True) while True: client, _ = listener.accept() - with client: - reader = client.makefile("r", encoding="utf-8") - writer = client.makefile("w", encoding="utf-8") - writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n") - writer.flush() - for line in reader: - request = json.loads(line) - command = request["command"] - data = {"state": "Paused", "breakpoints": 0} if command == "status" else {"breakpoints": []} - writer.write(json.dumps({"ok": True, "command": command, "data": data}) + "\\n") + try: + with client: + reader = client.makefile("r", encoding="utf-8") + writer = client.makefile("w", encoding="utf-8") + writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n") writer.flush() + for line in reader: + request = json.loads(line) + command = request["command"] + data = {"state": "Paused", "breakpoints": 0} if command == "status" else {"breakpoints": []} + writer.write(json.dumps({"ok": True, "command": command, "data": data}) + "\\n") + writer.flush() + except OSError: + pass """), encoding="utf-8") emulator.chmod(0o755) + self.process_manager = EmulatorProcessManager(self.bridge.journal, str(emulator)) + self.http_server.RequestHandlerClass.process_manager = self.process_manager with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation: reservation.bind(("127.0.0.1", 0)) debug_port = reservation.getsockname()[1] - launched = self.post("/api/launch", { - "ebootPath": str(eboot), - "debugPort": debug_port, - "emulatorPath": str(emulator), - }) + try: + launched = self.post("/api/launch", { + "ebootPath": str(eboot), + "debugPort": debug_port, + }) + except HTTPError as exc: + self.fail(exc.read().decode("utf-8", "replace")) self.assertTrue(launched["connected"]) self.assertTrue(launched["emulator"]["running"]) self.assertEqual(str(eboot), launched["emulator"]["eboot"]) @@ -286,6 +343,108 @@ class FrontendHttpTests(unittest.TestCase): self.assertFalse(stopped["connected"]) self.assertFalse(stopped["emulator"]["running"]) + def test_post_without_csrf_token_is_rejected(self) -> None: + result = self.assert_post_rejected( + "/api/launch", + {"ebootPath": "ignored", "debugPort": 5714}, + 403, + include_token=False, + ) + self.assertIn("CSRF", str(result["error"])) + + def test_reported_text_plain_no_cors_launch_is_rejected(self) -> None: + result = self.assert_post_rejected( + "/api/launch", + { + "ebootPath": "/etc/hostname", + "debugPort": 5999, + "emulatorPath": "/tmp/payload.sh", + }, + 403, + include_token=False, + origin="https://evil.example", + content_type="text/plain;charset=UTF-8", + ) + self.assertIn("CSRF", str(result["error"])) + + def test_json_content_type_is_required_after_authentication(self) -> None: + result = self.assert_post_rejected( + "/api/disconnect", + {}, + 400, + content_type="text/plain", + ) + self.assertIn("Content-Type", str(result["error"])) + + def test_cross_origin_post_is_rejected_even_with_token(self) -> None: + result = self.assert_post_rejected( + "/api/disconnect", + {}, + 403, + origin="https://attacker.example", + ) + self.assertIn("Origin", str(result["error"])) + + def test_cross_site_fetch_metadata_is_rejected(self) -> None: + result = self.assert_post_rejected( + "/api/disconnect", + {}, + 403, + fetch_site="cross-site", + ) + self.assertIn("Cross-origin", str(result["error"])) + + def test_post_without_browser_provenance_is_rejected(self) -> None: + result = self.assert_post_rejected( + "/api/disconnect", + {}, + 403, + origin=None, + ) + self.assertIn("Origin or Referer", str(result["error"])) + + def test_same_origin_referer_is_accepted_when_origin_is_absent(self) -> None: + snapshot = self.post("/api/disconnect", {}, origin=None, referer="same-origin") + self.assertFalse(snapshot["connected"]) + + def test_non_loopback_host_header_is_rejected(self) -> None: + result = self.assert_post_rejected( + "/api/disconnect", + {}, + 403, + host=f"attacker.example:{self.http_port}", + ) + self.assertIn("Host", str(result["error"])) + + def test_launch_rejects_client_supplied_emulator_path(self) -> None: + result = self.assert_post_rejected( + "/api/launch", + { + "ebootPath": "ignored", + "debugPort": 5714, + "emulatorPath": "C:/Windows/System32/calc.exe", + }, + 400, + ) + self.assertIn("cannot be supplied over HTTP", str(result["error"])) + + def test_connect_rejects_remote_ssrf_target_by_default(self) -> None: + result = self.assert_post_rejected( + "/api/connect", + {"host": "192.0.2.1", "port": 80}, + 502, + ) + self.assertIn("Remote debugger connections are disabled", str(result["error"])) + + def test_api_get_requires_token(self) -> None: + with self.assertRaises(HTTPError) as caught: + urlopen(f"http://127.0.0.1:{self.http_port}/api/snapshot", timeout=2) + self.assertEqual(403, caught.exception.code) + + def test_frontend_cannot_bind_beyond_loopback(self) -> None: + with self.assertRaisesRegex(SystemExit, "only listen on localhost"): + create_frontend_server("0.0.0.0", 0, self.http_server.RequestHandlerClass) + def test_rerun_replaces_existing_frontend_on_same_port(self) -> None: replacement_handler = type( "ReplacementFrontendRequestHandler", @@ -300,7 +459,11 @@ class FrontendHttpTests(unittest.TestCase): self.http_server = replacement self.http_thread = threading.Thread(target=replacement.serve_forever, daemon=True) self.http_thread.start() - with urlopen(f"http://127.0.0.1:{self.http_port}/api/health", timeout=2) as response: + health_request = Request( + f"http://127.0.0.1:{self.http_port}/api/health", + headers={CSRF_HEADER: replacement.csrf_token}, + ) + with urlopen(health_request, timeout=2) as response: health = json.load(response) self.assertEqual("sharpemu-debugger-frontend", health["application"]) diff --git a/tools/SharpEmu.DebuggerFrontend/web/app.js b/tools/SharpEmu.DebuggerFrontend/web/app.js index 3c795229..3ddbdcbd 100644 --- a/tools/SharpEmu.DebuggerFrontend/web/app.js +++ b/tools/SharpEmu.DebuggerFrontend/web/app.js @@ -20,12 +20,21 @@ const ui = { }; const byId = (id) => document.getElementById(id); +const csrfToken = document.querySelector('meta[name="sharpemu-csrf-token"]')?.content || ""; async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.body !== undefined) headers.set("Content-Type", "application/json"); + if (path.startsWith("/api/")) { + if (!csrfToken) throw new Error("Frontend security token is unavailable. Reload the page."); + headers.set("X-SharpEmu-CSRF-Token", csrfToken); + } const response = await fetch(path, { - headers: { "Content-Type": "application/json" }, cache: "no-store", + credentials: "same-origin", + redirect: "error", ...options, + headers, }); let payload; try { diff --git a/tools/SharpEmu.DebuggerFrontend/web/index.html b/tools/SharpEmu.DebuggerFrontend/web/index.html index 15596443..dbe3377f 100644 --- a/tools/SharpEmu.DebuggerFrontend/web/index.html +++ b/tools/SharpEmu.DebuggerFrontend/web/index.html @@ -8,6 +8,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + SharpEmu Debugger