security+robustness(py/gui): thread-safety, upload hardening, keyring
macro_manager: - All macro reads/writes guarded by a lock; macros.json written atomically (temp + fsync + os.replace). Commands are copied under the lock and executed outside it so wait/keyboard steps don't block other threads. - last_used disk writes debounced (>=5s) instead of a full rewrite per press. - Validate key/hotkey names against pyautogui.KEYBOARD_KEYS and clamp wait ms; invalid input is skipped/logged, not blindly executed. web_server: - WebSocket receive loop always disconnects (fixes dead-socket leak); broadcast prunes failed sockets. - POST /api/execute offloaded to a thread executor so a long macro no longer blocks the event loop. - Image upload hardened: 5MB cap (413), real-image validation via Pillow, server-generated name under macro_images/, returns a relative path only, cleans up on failure. gui: - Execute macros off the Qt main thread (signal back to status bar); debounce resizeEvent and only rebuild when the column count changes. - Relay password stored in the OS keyring when available (plaintext JSON blanked; graceful fallback if no backend); delete dialogs name the macro; honor the minimize-to-tray setting; surface save failures. - Add media/system keys (volume, play/pause, track nav) to the macro editor. - THEME gains shared danger/accent-hover colors; drop dead imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+88
-15
@@ -1,7 +1,9 @@
|
||||
# FastAPI web server for MacroPad
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import hmac
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
@@ -11,10 +13,23 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, H
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from PIL import Image
|
||||
import uvicorn
|
||||
|
||||
from config import DEFAULT_PORT, VERSION
|
||||
|
||||
# Max accepted upload size for images (bytes).
|
||||
MAX_IMAGE_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||
|
||||
# Map Pillow image formats to safe file extensions we're willing to persist.
|
||||
_IMAGE_FORMAT_EXT = {
|
||||
"PNG": ".png",
|
||||
"JPEG": ".jpg",
|
||||
"GIF": ".gif",
|
||||
"BMP": ".bmp",
|
||||
"WEBP": ".webp",
|
||||
}
|
||||
|
||||
|
||||
def get_resource_path(relative_path):
|
||||
"""Get the path to a bundled resource file."""
|
||||
@@ -73,12 +88,16 @@ class ConnectionManager:
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Send message to all connected clients."""
|
||||
for connection in self.active_connections:
|
||||
"""Send message to all connected clients, pruning dead sockets."""
|
||||
dead = []
|
||||
# Iterate over a snapshot so the list isn't mutated during iteration.
|
||||
for connection in list(self.active_connections):
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
dead.append(connection)
|
||||
for connection in dead:
|
||||
self.disconnect(connection)
|
||||
|
||||
|
||||
class WebServer:
|
||||
@@ -190,7 +209,12 @@ class WebServer:
|
||||
@app.post("/api/execute")
|
||||
async def execute_macro(request: ExecuteRequest):
|
||||
"""Execute a macro by ID."""
|
||||
success = self.macro_manager.execute_macro(request.macro_id)
|
||||
# execute_macro is synchronous and may sleep (wait steps); run it in
|
||||
# a thread executor so it doesn't block the event loop / other traffic.
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
None, self.macro_manager.execute_macro, request.macro_id
|
||||
)
|
||||
if success:
|
||||
# Broadcast execution to all connected clients
|
||||
await self.manager.broadcast({
|
||||
@@ -239,18 +263,60 @@ class WebServer:
|
||||
|
||||
@app.post("/api/upload-image")
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
"""Upload an image for a macro."""
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
"""Upload an image for a macro.
|
||||
|
||||
# Save to temp location
|
||||
import tempfile
|
||||
import shutil
|
||||
Hardened: enforces a size cap, validates the bytes are a real image
|
||||
with Pillow (content_type is untrusted), and saves into the app's
|
||||
macro_images/ directory under a server-generated uuid name. Returns
|
||||
only a relative reference, never an absolute server path.
|
||||
"""
|
||||
# Read with a hard size cap (reject oversize before buffering it all).
|
||||
data = b""
|
||||
chunk_size = 64 * 1024
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(data) > MAX_IMAGE_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Image too large")
|
||||
|
||||
ext = os.path.splitext(file.filename)[1] if file.filename else ".png"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
||||
shutil.copyfileobj(file.file, tmp)
|
||||
return {"path": tmp.name}
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Empty upload")
|
||||
|
||||
# Validate it's really an image (don't trust content_type).
|
||||
try:
|
||||
Image.open(io.BytesIO(data)).verify()
|
||||
# verify() leaves the image unusable; reopen to read the format.
|
||||
img_format = (Image.open(io.BytesIO(data)).format or "").upper()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="File is not a valid image")
|
||||
|
||||
ext = _IMAGE_FORMAT_EXT.get(img_format)
|
||||
if not ext:
|
||||
raise HTTPException(status_code=400, detail="Unsupported image format")
|
||||
|
||||
images_dir = os.path.join(self.app_dir, "macro_images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
dest_path = os.path.join(images_dir, filename)
|
||||
rel_path = os.path.join("macro_images", filename)
|
||||
|
||||
try:
|
||||
with open(dest_path, "wb") as out:
|
||||
out.write(data)
|
||||
except Exception:
|
||||
# Clean up a partial file on failure.
|
||||
try:
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="Failed to save image")
|
||||
|
||||
# Return only a relative, opaque reference.
|
||||
return {"path": rel_path.replace(os.sep, "/")}
|
||||
|
||||
@app.get("/api/image/{image_path:path}")
|
||||
async def get_image(image_path: str):
|
||||
@@ -276,11 +342,18 @@ class WebServer:
|
||||
await self.manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
# receive_json raises on malformed input / non-JSON frames;
|
||||
# any such error must still tear the connection down cleanly.
|
||||
data = await websocket.receive_json()
|
||||
# Handle incoming messages if needed
|
||||
if data.get("type") == "ping":
|
||||
if isinstance(data, dict) and data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
# Malformed input or any other error: don't leak the connection.
|
||||
pass
|
||||
finally:
|
||||
self.manager.disconnect(websocket)
|
||||
|
||||
self.app = app
|
||||
|
||||
Reference in New Issue
Block a user