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:
+26
-1
@@ -12,11 +12,14 @@ class MacroPadApp {
|
||||
this.relayMode = this.detectRelayMode();
|
||||
this.sessionId = null;
|
||||
this.password = null;
|
||||
this.localToken = null;
|
||||
this.desktopConnected = true;
|
||||
this.wsAuthenticated = false;
|
||||
|
||||
if (this.relayMode) {
|
||||
this.initRelayMode();
|
||||
} else {
|
||||
this.initLocalMode();
|
||||
}
|
||||
|
||||
this.init();
|
||||
@@ -51,6 +54,20 @@ class MacroPadApp {
|
||||
console.log('Relay mode enabled, session:', this.sessionId);
|
||||
}
|
||||
|
||||
initLocalMode() {
|
||||
// Get token from URL query param or localStorage (local/LAN mode)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.localToken = urlParams.get('token') || localStorage.getItem('macropad_local_token');
|
||||
|
||||
if (this.localToken) {
|
||||
// Persist token and strip it from the URL for cleanliness
|
||||
localStorage.setItem('macropad_local_token', this.localToken);
|
||||
if (urlParams.has('token')) {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getApiUrl(path) {
|
||||
if (this.relayMode && this.sessionId) {
|
||||
return `/${this.sessionId}${path}`;
|
||||
@@ -62,6 +79,8 @@ class MacroPadApp {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.relayMode && this.password) {
|
||||
headers['X-MacroPad-Password'] = this.password;
|
||||
} else if (!this.relayMode && this.localToken) {
|
||||
headers['X-MacroPad-Token'] = this.localToken;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -176,6 +195,10 @@ class MacroPadApp {
|
||||
wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
|
||||
} else {
|
||||
wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
// Append token as query param (WebSocket can't set headers)
|
||||
if (this.localToken) {
|
||||
wsUrl += `?token=${encodeURIComponent(this.localToken)}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -311,9 +334,11 @@ class MacroPadApp {
|
||||
let imageSrc = null;
|
||||
if (macro.image_path) {
|
||||
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
|
||||
// Add password as query param for relay mode (img tags can't use headers)
|
||||
// Add credential as query param since img tags can't use headers
|
||||
if (this.relayMode && this.password) {
|
||||
imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`;
|
||||
} else if (!this.relayMode && this.localToken) {
|
||||
imageSrc = `${basePath}?token=${encodeURIComponent(this.localToken)}`;
|
||||
} else {
|
||||
imageSrc = basePath;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user