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:
+14
-5
@@ -146,7 +146,12 @@ class MainWindow(QMainWindow):
|
||||
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
|
||||
|
||||
# Initialize web server
|
||||
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
|
||||
token = self.settings_manager.get_web_auth_token()
|
||||
host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1"
|
||||
self.web_server = WebServer(
|
||||
self.macro_manager, app_dir, DEFAULT_PORT,
|
||||
auth_token=token, host=host
|
||||
)
|
||||
self.server_thread = None
|
||||
|
||||
# Relay client (initialized later if enabled)
|
||||
@@ -450,7 +455,10 @@ class MainWindow(QMainWindow):
|
||||
self.ip_label.setText(full_url)
|
||||
return
|
||||
|
||||
# Fall back to local IP
|
||||
# Fall back to local IP. Append the auth token as a query param so the
|
||||
# QR/copied URL lets a LAN device authenticate against the API.
|
||||
token = self.settings_manager.get_web_auth_token()
|
||||
token_qs = f"?token={token}" if token else ""
|
||||
try:
|
||||
import netifaces
|
||||
for iface in netifaces.interfaces():
|
||||
@@ -459,11 +467,11 @@ class MainWindow(QMainWindow):
|
||||
for addr in addrs[netifaces.AF_INET]:
|
||||
ip = addr.get('addr', '')
|
||||
if ip and not ip.startswith('127.'):
|
||||
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
|
||||
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
|
||||
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}")
|
||||
|
||||
def copy_url_to_clipboard(self):
|
||||
"""Copy the web interface URL to clipboard."""
|
||||
@@ -703,7 +711,8 @@ class MainWindow(QMainWindow):
|
||||
local_port=DEFAULT_PORT,
|
||||
on_connected=self.on_relay_connected,
|
||||
on_disconnected=self.on_relay_disconnected,
|
||||
on_session_id=self.on_relay_session_id
|
||||
on_session_id=self.on_relay_session_id,
|
||||
local_auth_token=self.settings_manager.get_web_auth_token()
|
||||
)
|
||||
self.relay_client.start()
|
||||
self.status_bar.showMessage("Connecting to relay server...")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user