mirror of
https://github.com/par274/sharpemu.git
synced 2026-09-05 16:20:22 +08:00
Fix debugger frontend (#858)
* [security] harden debugger frontend API * [docs] document debugger frontend security boundaries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"<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)
|
||||
except (OSError, URLError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
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(
|
||||
f"{base_url}/api/shutdown",
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
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.
|
||||
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"<title>SharpEmu Debugger</title>" 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:
|
||||
|
||||
@@ -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 <span>Debugger</span>", 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"])
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="sharpemu-csrf-token" content="__SHARPEMU_CSRF_TOKEN__">
|
||||
<title>SharpEmu Debugger</title>
|
||||
<link rel="icon" type="image/webp" href="/sharpemu-logo.webp">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
|
||||
Reference in New Issue
Block a user