Compare commits

...

2 Commits

Author SHA1 Message Date
ParantezTech f63882c938 [docs] document debugger frontend security boundaries 2026-09-03 00:02:53 +03:00
ParantezTech 5930463a33 [security] harden debugger frontend API 2026-09-03 00:02:34 +03:00
5 changed files with 493 additions and 80 deletions

View File

@ -48,8 +48,12 @@ Useful options:
```text ```text
--debug-host HOST Debug server host (default 127.0.0.1) --debug-host HOST Debug server host (default 127.0.0.1)
--debug-port PORT Debug server port (default 5714) --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) --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-connect Do not connect to SharpEmu automatically
--no-browser Do not open a browser automatically --no-browser Do not open a browser automatically
--verbose Print HTTP request logs --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 node --check tools/SharpEmu.DebuggerFrontend/web/app.js
``` ```
The HTTP service binds to loopback by default and has no authentication. Only The HTTP service only accepts `localhost`, `127.0.0.1`, or `::1` listeners and
use a non-loopback `--listen` address on a trusted network. 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 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 be entered manually. Closing the frontend also stops the emulator process it

View File

@ -10,22 +10,26 @@ import argparse
import copy import copy
from collections import deque from collections import deque
import errno import errno
import hmac
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json import json
import os import os
import platform import platform
import queue import queue
import re import re
import secrets
from pathlib import Path from pathlib import Path
import shutil import shutil
import signal import signal
import socket import socket
import subprocess import subprocess
import sys
import threading import threading
import time import time
from typing import Any 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.error import URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
import webbrowser import webbrowser
@ -37,6 +41,9 @@ WEB_ROOT = APP_ROOT / "web"
MAX_REQUEST_BYTES = 1024 * 1024 MAX_REQUEST_BYTES = 1024 * 1024
MAX_JOURNAL_MESSAGES = 1000 MAX_JOURNAL_MESSAGES = 1000
APPLICATION_ID = "sharpemu-debugger-frontend" 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]] = { KNOWN_WAIT_IMPORTS: dict[str, tuple[str, str, str]] = {
"9UK1vLZQft4": ("libKernel", "scePthreadMutexLock", "mutex"), "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: def analyze_debug_stop(stop: dict[str, Any]) -> dict[str, Any] | None:
"""Turns structured or legacy stall evidence into actionable guidance.""" """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.""" """Raised when the debugger bridge cannot complete an operation."""
class RequestValidationError(RuntimeError):
"""Raised when an HTTP request violates the frontend API contract."""
class EventJournal: class EventJournal:
"""A bounded, cursor-addressable activity stream for the browser.""" """A bounded, cursor-addressable activity stream for the browser."""
@ -268,8 +302,9 @@ class EventJournal:
class EmulatorProcessManager: class EmulatorProcessManager:
"""Launches and supervises the one emulator process owned by the UI.""" """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.journal = journal
self._configured_emulator_path = emulator_path
self._lock = threading.RLock() self._lock = threading.RLock()
self._process: subprocess.Popen[str] | None = None self._process: subprocess.Popen[str] | None = None
self._reader_thread: threading.Thread | None = None self._reader_thread: threading.Thread | None = None
@ -282,7 +317,6 @@ class EmulatorProcessManager:
self, self,
eboot_path: str, eboot_path: str,
debug_port: int, debug_port: int,
emulator_path: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
eboot = Path(eboot_path).expanduser().resolve() eboot = Path(eboot_path).expanduser().resolve()
if not eboot.is_file(): if not eboot.is_file():
@ -292,7 +326,7 @@ class EmulatorProcessManager:
if self._port_is_open("127.0.0.1", debug_port): 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.") 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: with self._lock:
self._refresh_process_locked() self._refresh_process_locked()
if self._process is not None and self._process.poll() is None: 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', '')}" environment["PATH"] = f"{local_dotnet}{os.pathsep}{environment.get('PATH', '')}"
command = [ command = [
str(executable), *([sys.executable, str(executable)] if executable.suffix.lower() == ".py" else [str(executable)]),
f"--debug-server=127.0.0.1:{debug_port}", f"--debug-server=127.0.0.1:{debug_port}",
str(eboot), str(eboot),
] ]
@ -432,9 +466,9 @@ class EmulatorProcessManager:
selected = result.stdout.strip() selected = result.stdout.strip()
return str(Path(selected).expanduser().resolve()) if selected else None return str(Path(selected).expanduser().resolve()) if selected else None
def resolve_emulator(self, explicit_path: str | None = None) -> Path: def resolve_emulator(self) -> Path:
if explicit_path: if self._configured_emulator_path:
candidate = Path(explicit_path).expanduser().resolve() candidate = Path(self._configured_emulator_path).expanduser().resolve()
else: else:
system = platform.system().lower() system = platform.system().lower()
machine = platform.machine().lower() machine = platform.machine().lower()
@ -533,9 +567,15 @@ class EmulatorProcessManager:
class DebuggerBridge: class DebuggerBridge:
"""Owns one debugger socket and serializes request/reply traffic.""" """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_host = default_host
self.default_port = default_port self.default_port = default_port
self.allow_remote = allow_remote
self.journal = EventJournal() self.journal = EventJournal()
self._state_lock = threading.RLock() self._state_lock = threading.RLock()
@ -564,18 +604,28 @@ class DebuggerBridge:
host = host.strip() host = host.strip()
if not host: if not host:
raise BridgeError("A debugger host is required.") 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: if port < 1 or port > 65535:
raise BridgeError("The debugger port must be between 1 and 65535.") raise BridgeError("The debugger port must be between 1 and 65535.")
self.disconnect(log=False) self.disconnect(log=False)
self._drain_responses() self._drain_responses()
try: 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.settimeout(None)
client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
reader = client.makefile("r", encoding="utf-8", newline="\n") reader = client.makefile("r", encoding="utf-8", newline="\n")
except OSError as exc: 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) self.journal.append("error", message)
raise BridgeError(message) from exc raise BridgeError(message) from exc
@ -584,7 +634,7 @@ class DebuggerBridge:
generation = self._generation generation = self._generation
self._socket = client self._socket = client
self._connected = True self._connected = True
self._endpoint = f"{host}:{port}" self._endpoint = f"{socket_host}:{port}"
self._protocol = None self._protocol = None
self._target_state = "Connecting" self._target_state = "Connecting"
self._last_stop = None self._last_stop = None
@ -592,7 +642,7 @@ class DebuggerBridge:
self._breakpoints = [] self._breakpoints = []
self._hello_event = threading.Event() 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( thread = threading.Thread(
target=self._read_loop, target=self._read_loop,
args=(generation, client, reader), args=(generation, client, reader),
@ -605,7 +655,7 @@ class DebuggerBridge:
self._hello_event.wait(timeout=min(timeout, 1.5)) self._hello_event.wait(timeout=min(timeout, 1.5))
with self._state_lock: with self._state_lock:
if generation != self._generation or not self._connected: 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: def disconnect(self, reason: str = "Disconnected", log: bool = True) -> None:
with self._lifecycle_lock: with self._lifecycle_lock:
@ -715,9 +765,12 @@ class DebuggerBridge:
reason = "Debugger closed the connection." reason = "Debugger closed the connection."
try: try:
while True: while True:
line = reader.readline() line = reader.readline(MAX_REQUEST_BYTES + 1)
if line == "": if line == "":
break break
if len(line) > MAX_REQUEST_BYTES:
reason = "Debugger sent an oversized protocol message."
break
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
@ -825,9 +878,16 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
bridge: DebuggerBridge bridge: DebuggerBridge
process_manager: EmulatorProcessManager process_manager: EmulatorProcessManager
verbose = False verbose = False
server_version = "SharpEmuDebuggerFrontend"
sys_version = ""
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self._validated_request_authority() is None:
return
parsed = urlparse(self.path) 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": if parsed.path == "/api/snapshot":
values = parse_qs(parsed.query) values = parse_qs(parsed.query)
try: try:
@ -861,20 +921,17 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
except OSError: except OSError:
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
return 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_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body))) 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.end_headers()
self.wfile.write(body) self.wfile.write(body)
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self._validated_post_authority() is None:
return
parsed = urlparse(self.path) parsed = urlparse(self.path)
try: try:
body = self._read_json_body() body = self._read_json_body()
@ -898,12 +955,14 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
if not isinstance(eboot_path, str) or not eboot_path.strip(): if not isinstance(eboot_path, str) or not eboot_path.strip():
raise BridgeError("Choose an eboot file before launching.") raise BridgeError("Choose an eboot file before launching.")
debug_port = int(body.get("debugPort", self.bridge.default_port)) debug_port = int(body.get("debugPort", self.bridge.default_port))
emulator_path = body.get("emulatorPath") if "emulatorPath" in body:
if emulator_path is not None and not isinstance(emulator_path, str): raise RequestValidationError(
raise BridgeError("'emulatorPath' must be a string.") "'emulatorPath' cannot be supplied over HTTP. "
"Configure it locally with --emulator-path."
)
self.bridge.disconnect(log=False) self.bridge.disconnect(log=False)
try: 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.process_manager.wait_for_debugger(debug_port)
self.bridge.connect("127.0.0.1", debug_port) self.bridge.connect("127.0.0.1", debug_port)
self._prime_snapshot() self._prime_snapshot()
@ -934,11 +993,18 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
self._send_json(HTTPStatus.OK, {"response": response}) self._send_json(HTTPStatus.OK, {"response": response})
return return
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown API endpoint."}) 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)}) 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."}) 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: def _prime_snapshot(self) -> None:
try: try:
self.bridge.request({"command": "status"}) self.bridge.request({"command": "status"})
@ -955,17 +1021,122 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
self.server.shutdown() self.server.shutdown()
self.server.server_close() 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: 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: 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: 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) 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 "{}") value = json.loads(raw.decode("utf-8") if raw else "{}")
if not isinstance(value, dict): if not isinstance(value, dict):
raise BridgeError("Expected a JSON object request body.") raise RequestValidationError("Expected a JSON object request body.")
return value return value
def _send_json(self, status: HTTPStatus, payload: Any) -> None: def _send_json(self, status: HTTPStatus, payload: Any) -> None:
@ -974,13 +1145,27 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body))) 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.end_headers()
self.wfile.write(body) self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError): except OSError:
pass 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: def log_message(self, format_string: str, *args: Any) -> None:
if self.verbose: if self.verbose:
super().log_message(format_string, *args) super().log_message(format_string, *args)
@ -988,7 +1173,17 @@ class FrontendRequestHandler(BaseHTTPRequestHandler):
class FrontendHttpServer(ThreadingHTTPServer): class FrontendHttpServer(ThreadingHTTPServer):
daemon_threads = True 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( def create_frontend_server(
@ -998,8 +1193,13 @@ def create_frontend_server(
) -> FrontendHttpServer: ) -> FrontendHttpServer:
"""Binds the UI server, replacing an older frontend on the same port.""" """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: try:
return FrontendHttpServer((listen_address, port), handler_type) return _bind_frontend_server(listen_address, port, handler_type)
except OSError as exc: except OSError as exc:
if exc.errno != errno.EADDRINUSE or port == 0: if exc.errno != errno.EADDRINUSE or port == 0:
raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None 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 deadline = time.monotonic() + 10.0
while time.monotonic() < deadline: while time.monotonic() < deadline:
try: try:
return FrontendHttpServer((listen_address, port), handler_type) return _bind_frontend_server(listen_address, port, handler_type)
except OSError as exc: except OSError as exc:
if exc.errno != errno.EADDRINUSE: if exc.errno != errno.EADDRINUSE:
raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None 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.") 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: def request_existing_frontend_shutdown(listen_address: str, port: int) -> bool:
"""Stops only a verified SharpEmu frontend listening on the target port.""" """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 url_host = f"[{probe_host}]" if ":" in probe_host and not probe_host.startswith("[") else probe_host
base_url = f"http://{url_host}:{port}" base_url = f"http://{url_host}:{port}"
try: 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"<title>SharpEmu Debugger</title>" not in page:
return False
token_match = re.search(
rb'<meta\s+name="sharpemu-csrf-token"\s+content="([A-Za-z0-9_-]{32,})"',
page,
)
csrf_token = token_match.group(1).decode("ascii") if token_match else None
health_headers = {CSRF_HEADER: csrf_token} if csrf_token else {}
try:
health_request = Request(f"{base_url}/api/health", headers=health_headers)
with urlopen(health_request, timeout=0.75) as response:
health = json.load(response) health = json.load(response)
except (OSError, URLError, ValueError, json.JSONDecodeError): except (OSError, URLError, ValueError, json.JSONDecodeError):
return False return False
if isinstance(health, dict) and health.get("application") == APPLICATION_ID: if isinstance(health, dict) and health.get("application") == APPLICATION_ID:
headers = {"Content-Type": "application/json", "Origin": base_url}
if csrf_token:
headers[CSRF_HEADER] = csrf_token
request = Request( request = Request(
f"{base_url}/api/shutdown", f"{base_url}/api/shutdown",
data=b"{}", data=b"{}",
headers={"Content-Type": "application/json"}, headers=headers,
method="POST", method="POST",
) )
try: try:
@ -1052,13 +1283,6 @@ def request_existing_frontend_shutdown(listen_address: str, port: int) -> bool:
# that legacy instance to exit through its normal Ctrl+C cleanup path. # that legacy instance to exit through its normal Ctrl+C cleanup path.
if not isinstance(health, dict) or health.get("ok") is not True: if not isinstance(health, dict) or health.get("ok") is not True:
return False 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"<title>SharpEmu Debugger</title>" not in page:
return False
legacy_pid = find_legacy_frontend_pid(port) legacy_pid = find_legacy_frontend_pid(port)
if legacy_pid is None: if legacy_pid is None:
return False 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("--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("--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("--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-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("--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") 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: if args.ui_port < 0 or args.ui_port > 65535:
raise SystemExit("--ui-port must be between 0 and 65535") raise SystemExit("--ui-port must be between 0 and 65535")
bridge = DebuggerBridge(args.debug_host, args.debug_port) bridge = DebuggerBridge(args.debug_host, args.debug_port, allow_remote=args.allow_remote_debugger)
process_manager = EmulatorProcessManager(bridge.journal) process_manager = EmulatorProcessManager(bridge.journal, args.emulator_path)
handler_type = type( handler_type = type(
"ConfiguredFrontendRequestHandler", "ConfiguredFrontendRequestHandler",
(FrontendRequestHandler,), (FrontendRequestHandler,),
@ -1126,15 +1356,13 @@ def main(argv: list[str] | None = None) -> int:
) )
server = create_frontend_server(args.listen, args.ui_port, handler_type) server = create_frontend_server(args.listen, args.ui_port, handler_type)
actual_port = server.server_address[1] actual_port = server.server_address[1]
browser_host = args.listen if args.listen not in {"0.0.0.0", "::"} else "127.0.0.1" browser_host = _normalize_http_host(args.listen) or "127.0.0.1"
url = f"http://{browser_host}:{actual_port}/" 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"SharpEmu Debugger Frontend: {url}")
print(f"Debugger endpoint: {args.debug_host}:{args.debug_port}") print(f"Debugger endpoint: {args.debug_host}:{args.debug_port}")
print("Press Ctrl+C to stop.") 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: if not args.no_connect:
def connect_default() -> None: def connect_default() -> None:
try: try:

View File

@ -11,6 +11,7 @@ import tempfile
import textwrap import textwrap
import threading import threading
import unittest import unittest
from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@ -19,6 +20,7 @@ sys.path.insert(0, str(FRONTEND_ROOT))
from debugger_frontend import ( # noqa: E402 from debugger_frontend import ( # noqa: E402
BridgeError, BridgeError,
CSRF_HEADER,
DebuggerBridge, DebuggerBridge,
EmulatorProcessManager, EmulatorProcessManager,
FrontendHttpServer, FrontendHttpServer,
@ -145,6 +147,11 @@ class DebuggerBridgeTests(unittest.TestCase):
with self.assertRaises(BridgeError): with self.assertRaises(BridgeError):
self.bridge.request({}) 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: def test_journal_cursor_returns_only_new_messages(self) -> None:
cursor = self.bridge.snapshot()["cursor"] cursor = self.bridge.snapshot()["cursor"]
self.bridge.request({"command": "status"}) 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_server = FrontendHttpServer(("127.0.0.1", 0), handler)
self.http_port = self.http_server.server_address[1] 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 = threading.Thread(target=self.http_server.serve_forever, daemon=True)
self.http_thread.start() self.http_thread.start()
@ -204,16 +212,54 @@ class FrontendHttpTests(unittest.TestCase):
self.http_thread.join(timeout=1) self.http_thread.join(timeout=1)
self.debugger.close() 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( request = Request(
f"http://127.0.0.1:{self.http_port}{path}", f"http://127.0.0.1:{self.http_port}{path}",
data=json.dumps(payload).encode("utf-8"), data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, headers=headers,
method="POST", method="POST",
) )
with urlopen(request, timeout=2) as response: with urlopen(request, timeout=5) as response:
return json.load(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: def test_api_connect_and_command_round_trip(self) -> None:
connected = self.post("/api/connect", {"host": "127.0.0.1", "port": self.debugger.port}) connected = self.post("/api/connect", {"host": "127.0.0.1", "port": self.debugger.port})
self.assertTrue(connected["connected"]) 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: with urlopen(f"http://127.0.0.1:{self.http_port}/", timeout=2) as response:
body = response.read().decode("utf-8") body = response.read().decode("utf-8")
self.assertEqual("text/html; charset=utf-8", response.headers["Content-Type"]) 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 <span>Debugger</span>", body) self.assertIn("SharpEmu <span>Debugger</span>", body)
self.assertIn('rel="icon" type="image/webp"', 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: with urlopen(f"http://127.0.0.1:{self.http_port}/sharpemu-logo.webp", timeout=2) as response:
logo = response.read() logo = response.read()
@ -239,7 +289,7 @@ class FrontendHttpTests(unittest.TestCase):
root = Path(temporary_directory) root = Path(temporary_directory)
eboot = root / "eboot.bin" eboot = root / "eboot.bin"
eboot.write_bytes(b"test") eboot.write_bytes(b"test")
emulator = root / "fake-sharpemu" emulator = root / "fake-sharpemu.py"
emulator.write_text(textwrap.dedent("""\ emulator.write_text(textwrap.dedent("""\
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
@ -255,29 +305,36 @@ class FrontendHttpTests(unittest.TestCase):
print("Fake SharpEmu debug server ready", flush=True) print("Fake SharpEmu debug server ready", flush=True)
while True: while True:
client, _ = listener.accept() client, _ = listener.accept()
with client: try:
reader = client.makefile("r", encoding="utf-8") with client:
writer = client.makefile("w", encoding="utf-8") reader = client.makefile("r", encoding="utf-8")
writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n") writer = client.makefile("w", encoding="utf-8")
writer.flush() writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n")
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() 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") """), encoding="utf-8")
emulator.chmod(0o755) 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: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
reservation.bind(("127.0.0.1", 0)) reservation.bind(("127.0.0.1", 0))
debug_port = reservation.getsockname()[1] debug_port = reservation.getsockname()[1]
launched = self.post("/api/launch", { try:
"ebootPath": str(eboot), launched = self.post("/api/launch", {
"debugPort": debug_port, "ebootPath": str(eboot),
"emulatorPath": str(emulator), "debugPort": debug_port,
}) })
except HTTPError as exc:
self.fail(exc.read().decode("utf-8", "replace"))
self.assertTrue(launched["connected"]) self.assertTrue(launched["connected"])
self.assertTrue(launched["emulator"]["running"]) self.assertTrue(launched["emulator"]["running"])
self.assertEqual(str(eboot), launched["emulator"]["eboot"]) self.assertEqual(str(eboot), launched["emulator"]["eboot"])
@ -286,6 +343,108 @@ class FrontendHttpTests(unittest.TestCase):
self.assertFalse(stopped["connected"]) self.assertFalse(stopped["connected"])
self.assertFalse(stopped["emulator"]["running"]) 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: def test_rerun_replaces_existing_frontend_on_same_port(self) -> None:
replacement_handler = type( replacement_handler = type(
"ReplacementFrontendRequestHandler", "ReplacementFrontendRequestHandler",
@ -300,7 +459,11 @@ class FrontendHttpTests(unittest.TestCase):
self.http_server = replacement self.http_server = replacement
self.http_thread = threading.Thread(target=replacement.serve_forever, daemon=True) self.http_thread = threading.Thread(target=replacement.serve_forever, daemon=True)
self.http_thread.start() 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) health = json.load(response)
self.assertEqual("sharpemu-debugger-frontend", health["application"]) self.assertEqual("sharpemu-debugger-frontend", health["application"])

View File

@ -20,12 +20,21 @@ const ui = {
}; };
const byId = (id) => document.getElementById(id); const byId = (id) => document.getElementById(id);
const csrfToken = document.querySelector('meta[name="sharpemu-csrf-token"]')?.content || "";
async function api(path, options = {}) { 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, { const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
cache: "no-store", cache: "no-store",
credentials: "same-origin",
redirect: "error",
...options, ...options,
headers,
}); });
let payload; let payload;
try { try {

View File

@ -8,6 +8,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="sharpemu-csrf-token" content="__SHARPEMU_CSRF_TOKEN__">
<title>SharpEmu Debugger</title> <title>SharpEmu Debugger</title>
<link rel="icon" type="image/webp" href="/sharpemu-logo.webp"> <link rel="icon" type="image/webp" href="/sharpemu-logo.webp">
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">