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:
+42
-9
@@ -2,13 +2,14 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import hmac
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import uvicorn
|
||||
|
||||
@@ -83,10 +84,13 @@ class ConnectionManager:
|
||||
class WebServer:
|
||||
"""FastAPI-based web server for MacroPad."""
|
||||
|
||||
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT):
|
||||
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT,
|
||||
auth_token: str = "", host: str = "127.0.0.1"):
|
||||
self.macro_manager = macro_manager
|
||||
self.app_dir = app_dir
|
||||
self.port = port
|
||||
self.auth_token = auth_token
|
||||
self.host = host
|
||||
self.app = None
|
||||
self.manager = ConnectionManager()
|
||||
self.server = None
|
||||
@@ -114,6 +118,25 @@ class WebServer:
|
||||
if os.path.exists(images_dir):
|
||||
app.mount("/images", StaticFiles(directory=images_dir), name="images")
|
||||
|
||||
# Paths served without a token (PWA shell + static assets)
|
||||
public_paths = {"/", "/manifest.json", "/service-worker.js"}
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""Require a valid auth token for all non-public paths."""
|
||||
path = request.url.path
|
||||
if path in public_paths or path.startswith("/static/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Fail-open only when no token is configured (shouldn't happen in prod)
|
||||
if self.auth_token:
|
||||
provided = request.headers.get("X-MacroPad-Token") \
|
||||
or request.query_params.get("token") or ""
|
||||
if not hmac.compare_digest(provided, self.auth_token):
|
||||
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
"""Serve the main PWA page."""
|
||||
@@ -231,15 +254,25 @@ class WebServer:
|
||||
|
||||
@app.get("/api/image/{image_path:path}")
|
||||
async def get_image(image_path: str):
|
||||
"""Get macro image (legacy compatibility)."""
|
||||
full_path = os.path.join(self.app_dir, image_path)
|
||||
if os.path.exists(full_path):
|
||||
return FileResponse(full_path)
|
||||
"""Get macro image (legacy compatibility, path-traversal safe)."""
|
||||
images_dir = os.path.join(self.app_dir, "macro_images")
|
||||
requested = os.path.realpath(os.path.join(self.app_dir, image_path))
|
||||
images_real = os.path.realpath(images_dir)
|
||||
# Only serve files that resolve to inside the macro_images directory
|
||||
if (os.path.commonpath([requested, images_real]) == images_real
|
||||
and os.path.isfile(requested)):
|
||||
return FileResponse(requested)
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket for real-time updates."""
|
||||
# Require a valid token before accepting the connection
|
||||
if self.auth_token:
|
||||
provided = websocket.query_params.get("token") or ""
|
||||
if not hmac.compare_digest(provided, self.auth_token):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await self.manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -260,7 +293,7 @@ class WebServer:
|
||||
|
||||
config = uvicorn.Config(
|
||||
self.app,
|
||||
host="0.0.0.0",
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
log_config=None # Disable default logging config for PyInstaller compatibility
|
||||
@@ -280,7 +313,7 @@ class WebServer:
|
||||
|
||||
config = uvicorn.Config(
|
||||
self.app,
|
||||
host="0.0.0.0",
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
log_config=None # Disable default logging config for PyInstaller compatibility
|
||||
|
||||
Reference in New Issue
Block a user