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:
2026-07-17 10:14:56 -07:00
parent 7922910bd8
commit 1f26427328
6 changed files with 139 additions and 20 deletions
+24
View File
@@ -2,6 +2,7 @@
import os
import json
import secrets
from typing import Any, Optional
@@ -12,6 +13,10 @@ DEFAULT_SETTINGS = {
"session_id": None,
"password": ""
},
"web": {
"auth_token": "",
"allow_lan": True
},
"minimize_to_tray": True
}
@@ -36,12 +41,23 @@ class SettingsManager:
# Merge with defaults to ensure all keys exist
self.settings = self._merge_defaults(DEFAULT_SETTINGS, self.settings)
# Ensure a web auth token exists (this is the security boundary for API access)
if not self.get('web.auth_token'):
token = secrets.token_urlsafe(32)
self.set('web.auth_token', token)
self.save()
def save(self):
"""Save settings to file."""
try:
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=2)
# Best-effort restrict permissions (protects stored relay password + token)
try:
os.chmod(self.settings_file, 0o600)
except OSError:
pass
return True
except IOError:
return False
@@ -101,6 +117,14 @@ class SettingsManager:
self.set('relay.session_id', session_id)
self.save()
def get_web_auth_token(self) -> str:
"""Get the web API auth token."""
return self.get('web.auth_token', '')
def get_web_allow_lan(self) -> bool:
"""Check if LAN (0.0.0.0) binding is allowed."""
return self.get('web.allow_lan', True)
def get_minimize_to_tray(self) -> bool:
"""Check if minimize to tray is enabled."""
return self.get('minimize_to_tray', True)