security(P0): close unauthenticated RCE chain
The local FastAPI server previously exposed macro create/execute over the
network with no authentication while bound to 0.0.0.0, and macro `app`
commands ran via subprocess with shell=True — an unauthenticated RCE for
anyone on the LAN (and, via the relay, the internet).
- Mandatory per-install auth token (secrets.token_urlsafe) required on all
/api/* routes and the /ws handshake; timing-safe hmac.compare_digest.
PWA shell + /static assets remain public to bootstrap.
- Token auto-generated and persisted in settings.json (now chmod 0600).
- Bind host is configurable (web.allow_lan); token is the security boundary.
- macro `app` launch no longer uses shell=True — commands are tokenized with
shlex and exec'd directly, killing ;/&&/|/$() shell chaining.
- Relay client injects the local token and only forwards /api/* paths, and no
longer forwards attacker-supplied headers to the local server.
- Fixed path traversal in /api/image/{path} (realpath-confined to macro_images).
- GUI embeds the token in the LAN URL/QR; PWA reads it and sends it on API,
WebSocket, and image requests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-3
@@ -20,7 +20,8 @@ class RelayClient:
|
||||
local_port: int = 40000,
|
||||
on_connected: Optional[Callable] = None,
|
||||
on_disconnected: Optional[Callable] = None,
|
||||
on_session_id: Optional[Callable[[str], None]] = None
|
||||
on_session_id: Optional[Callable[[str], None]] = None,
|
||||
local_auth_token: str = ""
|
||||
):
|
||||
self.relay_url = relay_url.rstrip('/')
|
||||
if not self.relay_url.endswith('/desktop'):
|
||||
@@ -28,6 +29,7 @@ class RelayClient:
|
||||
self.password = password
|
||||
self.session_id = session_id
|
||||
self.local_url = f"http://localhost:{local_port}"
|
||||
self.local_auth_token = local_auth_token
|
||||
|
||||
# Callbacks
|
||||
self.on_connected = on_connected
|
||||
@@ -174,16 +176,33 @@ class RelayClient:
|
||||
method = msg.get("method", "GET").upper()
|
||||
path = msg.get("path", "/")
|
||||
body = msg.get("body")
|
||||
headers = msg.get("headers", {})
|
||||
|
||||
# Restrict the relay to API routes only. This prevents the relay from
|
||||
# reaching static/other paths on the local server.
|
||||
if not path.startswith("/api/"):
|
||||
await self._ws.send_json({
|
||||
"type": "api_response",
|
||||
"requestId": request_id,
|
||||
"status": 403,
|
||||
"body": {"error": "forbidden path"}
|
||||
})
|
||||
return
|
||||
|
||||
url = f"{self.local_url}{path}"
|
||||
|
||||
# Build a fresh headers dict. We do NOT forward attacker-supplied
|
||||
# headers from the relay; we inject the local auth token ourselves.
|
||||
sending_body = bool(body) and method in ("POST", "PUT", "PATCH")
|
||||
headers = {"X-MacroPad-Token": self.local_auth_token}
|
||||
if sending_body:
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
try:
|
||||
# Forward request to local server
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
json=body if body and method in ("POST", "PUT", "PATCH") else None,
|
||||
json=body if sending_body else None,
|
||||
headers=headers
|
||||
) as response:
|
||||
# Handle binary responses (images)
|
||||
|
||||
Reference in New Issue
Block a user