Security hardening (P0 RCE + audit follow-through), web macro editor, and redesign #6

Merged
jknapp merged 11 commits from security/p0-rce-hardening into main 2026-07-18 04:08:24 +00:00
Owner

Summary

Closes the unauthenticated remote-code-execution chain found in a security audit and continues through the rest of the audit's findings, adding web-based macro authoring and a visual redesign. 7 commits, grouped by concern.

Security

  • P0 — unauthenticated RCE closed. Mandatory per-install auth token on all /api/* routes and the /ws handshake (timing-safe compare); PWA shell stays public to bootstrap. Macro app commands no longer run through a shell (shell=True removed; shlex-tokenized, exec'd directly) — kills ;/&&/|/$()/redirection injection. LAN binding is configurable (web.allow_lan). Image path-traversal in /api/image fixed. Anti-clickjacking response headers.
  • Relay server hardened. WS auth brute-force lockout + socket close; IP rate-limiting and Origin allowlist on WS upgrades; /health no longer leaks session IDs; credentials removed from URLs (header-based); sessions bounded + TTL-pruned; 12-char session IDs with rejection sampling; atomic/debounced session store; helmet CSP, trust proxy, restricted CORS; sane .env.example.
  • Web client. DOM XSS eliminated (DOM construction only — no macro/tab value reaches innerHTML), strict CSP, no inline handlers, JSON.parse guarded.
  • Desktop/backend. Relay password moved to the OS keyring (0600 JSON fallback); settings file chmod 0600.

Robustness

  • Macro store: lock-guarded reads/writes, atomic macros.json writes, execution moved outside the lock, debounced last_used, key/wait validation.
  • Web server: WebSocket leak fixed, dead-socket pruning on broadcast, /api/execute offloaded off the event loop, image upload hardened (size cap, Pillow validation, server-generated name, relative path only).
  • GUI: macro execution off the Qt main thread, debounced resize, named delete dialogs, minimize_to_tray honored.
  • PWA: service worker network-first for the app shell, WS reconnect backoff with teardown, tab-switch race guarded.

Features & design

  • Web-based macro creation/editing — a "+" command-sequence builder in the PWA (previously execute-only).
  • Media/system keys (volume, play/pause, track nav) in the macro editor.
  • "Tactile Deck" visual redesign — semantic design tokens, light/dark themes, per-macro colors, accessible cards/tabs (roles, focus rings, reduced-motion).
  • README updated to match.

Verification

End-to-end against the real FastAPI server in a headless browser: token auth (401/200), WebSocket connect, macro created through the web editor (correct schema, atomic persist, live refresh), and the shell-injection RCE confirmed closed (a redirection payload did not execute). Relay TypeScript type-checks clean; Python compiles; concurrent macro-store writes verified deadlock-free.

Review round (included in this branch)

An automated completeness review of the branch confirmed all intended changes are present and the P0 RCE chain is genuinely closed, and caught two Major issues that were then fixed here:

  • Relay lockout DoS — the auth lockout was keyed on sessionId alone and shared between the web/desktop paths, letting anyone with the share link lock out the owner and block the desktop. Now namespaced per-path and per-IP.
  • Relay web client XSS — the DOM-XSS/CSP hardening had reached the local PWA but not the internet-facing macropad-relay/public/app.html. Now rebuilt with safe DOM construction, externalized scripts, and script-src 'self'.

All minor findings were also addressed (credentials out of local image URLs, local auth-failure state, tightened connect-src, relay enumeration-oracle removal, atomic session cap, backoff-after-auth, ALLOWED_ORIGINS deploy note). The relay web client is intentionally kept execute-only (documented in the README).

Not run interactively

The PySide6 desktop GUI and the live relay were reviewed + compile/type-checked but not exercised at runtime (no desktop session / relay deployment here) — worth a manual smoke test.

🤖 Generated with Claude Code

## Summary Closes the unauthenticated remote-code-execution chain found in a security audit and continues through the rest of the audit's findings, adding web-based macro authoring and a visual redesign. 7 commits, grouped by concern. ## Security - **P0 — unauthenticated RCE closed.** Mandatory per-install auth token on all `/api/*` routes and the `/ws` handshake (timing-safe compare); PWA shell stays public to bootstrap. Macro `app` commands no longer run through a shell (`shell=True` removed; `shlex`-tokenized, exec'd directly) — kills `;`/`&&`/`|`/`$()`/redirection injection. LAN binding is configurable (`web.allow_lan`). Image path-traversal in `/api/image` fixed. Anti-clickjacking response headers. - **Relay server hardened.** WS auth brute-force lockout + socket close; IP rate-limiting and Origin allowlist on WS upgrades; `/health` no longer leaks session IDs; credentials removed from URLs (header-based); sessions bounded + TTL-pruned; 12-char session IDs with rejection sampling; atomic/debounced session store; helmet CSP, trust proxy, restricted CORS; sane `.env.example`. - **Web client.** DOM XSS eliminated (DOM construction only — no macro/tab value reaches `innerHTML`), strict CSP, no inline handlers, `JSON.parse` guarded. - **Desktop/backend.** Relay password moved to the OS keyring (0600 JSON fallback); settings file chmod 0600. ## Robustness - Macro store: lock-guarded reads/writes, atomic `macros.json` writes, execution moved outside the lock, debounced `last_used`, key/wait validation. - Web server: WebSocket leak fixed, dead-socket pruning on broadcast, `/api/execute` offloaded off the event loop, image upload hardened (size cap, Pillow validation, server-generated name, relative path only). - GUI: macro execution off the Qt main thread, debounced resize, named delete dialogs, `minimize_to_tray` honored. - PWA: service worker network-first for the app shell, WS reconnect backoff with teardown, tab-switch race guarded. ## Features & design - **Web-based macro creation/editing** — a "+" command-sequence builder in the PWA (previously execute-only). - **Media/system keys** (volume, play/pause, track nav) in the macro editor. - **"Tactile Deck" visual redesign** — semantic design tokens, light/dark themes, per-macro colors, accessible cards/tabs (roles, focus rings, reduced-motion). - README updated to match. ## Verification End-to-end against the real FastAPI server in a headless browser: token auth (401/200), WebSocket connect, macro created through the web editor (correct schema, atomic persist, live refresh), and the shell-injection RCE confirmed closed (a redirection payload did not execute). Relay TypeScript type-checks clean; Python compiles; concurrent macro-store writes verified deadlock-free. ## Review round (included in this branch) An automated completeness review of the branch confirmed all intended changes are present and the P0 RCE chain is genuinely closed, and caught two Major issues that were then fixed here: - **Relay lockout DoS** — the auth lockout was keyed on `sessionId` alone and shared between the web/desktop paths, letting anyone with the share link lock out the owner and block the desktop. Now namespaced per-path and per-IP. - **Relay web client XSS** — the DOM-XSS/CSP hardening had reached the local PWA but not the internet-facing `macropad-relay/public/app.html`. Now rebuilt with safe DOM construction, externalized scripts, and `script-src 'self'`. All minor findings were also addressed (credentials out of local image URLs, local auth-failure state, tightened `connect-src`, relay enumeration-oracle removal, atomic session cap, backoff-after-auth, `ALLOWED_ORIGINS` deploy note). The relay web client is intentionally kept execute-only (documented in the README). ## Not run interactively The PySide6 desktop GUI and the live relay were reviewed + compile/type-checked but not exercised at runtime (no desktop session / relay deployment here) — worth a manual smoke test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 11 commits 2026-07-18 01:15:30 +00:00
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>
Closes the account-takeover chain and related issues found in audit:

- WS auth brute-force protection: per-session lockout (authThrottle) + per-
  socket failure cap that closes the socket (1008); applied to both web-client
  and desktop auth. Failed auth no longer leaves the socket open for unlimited
  guesses.
- WS upgrades now pass an IP-based rate limiter and Origin allowlist before
  handleUpgrade (previously bypassed all HTTP middleware); trust proxy set so
  limiting keys on the real client IP.
- /health no longer leaks live session IDs (counts only).
- .env.example rate-limit fixed (900000ms / 300) from the accidental ~11k rps.
- Credentials moved out of URLs into the X-MacroPad-Password header; images
  fetched via header + blob URLs; login stores creds in sessionStorage.
- Session creation bounded (max sessions, min password length) and TTL-pruned;
  session store writes are now atomic (temp+rename) and debounced.
- Session IDs lengthened to 12 chars with rejection sampling (no modulo bias).
- Enable helmet CSP; restrict CORS to configured origins.
- Request IDs use crypto.randomUUID; drop postinstall build hook and uuid dep.
- Uniform response for unknown session IDs (removes enumeration oracle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security:
- Eliminate DOM-based XSS: renderMacros/renderTabs rebuilt with
  createElement/textContent/setAttribute + addEventListener. No macro name,
  id, image_path, or category ever reaches innerHTML.
- Remove all inline on*= handlers; add a strict Content-Security-Policy meta
  (script-src 'self', object-src 'none', base-uri 'none', frame-ancestors
  'none').
- Guard JSON.parse on WebSocket frames.

Robustness:
- Service worker: network-first for the app shell + JS/CSS so fixes actually
  ship; synthetic 503 for offline API calls; drop no-op /ws intercept; bump
  cache version.
- WebSocket reconnect: exponential backoff (1s→30s), tear down old socket
  before reconnect, stop on pagehide/beforeunload.
- Tab-switch race fixed with a request-id guard; executing state cleared in
  finally.

Accessibility:
- Macro cards and tabs are real buttons with aria-labels/roles; toast +
  connection status are aria-live; removed user-scalable=no.

Feature — web-based macro creation/editing:
- Floating "+" button + modal command-sequence builder (text/key/hotkey/
  wait/app) with reorder/remove; create via POST, edit via PUT (prefilled
  from GET /api/macro/{id}), delete with a named confirm. Works in both local
  and relay auth modes. Empty state now offers "Create your first macro".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- Full semantic design-token layer (surfaces, spacing scale, radii, elevation,
  type scale) as the single source of truth; electric-violet signature accent
  instead of default blue.
- Light + dark themes via prefers-color-scheme; muted text now meets WCAG AA
  (replaces low-contrast #aaa/#888).
- Macro cards read as physical keys (raised, hairline highlight, per-macro
  --macro-color wash + placeholder, press/executing feedback, 2-line clamp).
- Proper modal sheet (blurred scrim, sticky header/footer, focus rings),
  thumb-reachable floating action button, 44px tap targets, app-wide
  :focus-visible, and prefers-reduced-motion support.
- manifest + theme-color meta updated to the new palette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
frame-ancestors is ignored inside a <meta> CSP, so move clickjacking
protection to real response headers (X-Frame-Options: DENY) and add
X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on all
responses. Drop the ineffective frame-ancestors token from the meta CSP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update README for the new security model and features: local API access
token embedded in the URL/QR (and web.allow_lan), app commands executing
without a shell, relay password in OS keyring, web-based macro creation,
and media/system keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses two Major findings from PR review:

- Auth lockout no longer griefable: the throttle key is namespaced per path
  and includes the client IP (web:<session>:<ip> vs desktop:<session>:<ip>),
  so a flood of bad web-client auths can neither lock other clients of the
  same session nor block the desktop from (re)authenticating. Per-socket
  failure close and brute-force lockout are preserved.
- Relay web client XSS eliminated: app.html tab/macro rendering rebuilt with
  createElement/textContent (no macro/tab value reaches innerHTML); inline
  scripts/handlers externalized to /static/app.js and /static/login.js so the
  helmet CSP now uses script-src 'self' (dropped 'unsafe-inline' for scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remote (relay) devices browse and trigger macros; macro authoring is
intentionally kept to the local network so what runs on the machine isn't
editable from the public internet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Minor PR-review follow-ups for the local PWA:
- Macro images load via a header-authenticated fetch -> blob object URL in both
  token and relay modes, so no credential ever sits in an image URL; prior
  object URLs are revoked on re-render.
- Local-mode auth failure now shows a clear "Access token required" state and
  stops the reconnect/toast loop (one-shot), instead of retrying forever.
- CSP connect-src tightened from 'self' ws: wss: to 'self' (same-origin WS is
  covered); verified the live WebSocket still connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Uniform auth responses remove the session-enumeration oracle: unknown
  session and wrong password now return an identical 401 at the API layer and
  an identical WS handshake/close; desktop-status and the 503 "not connected"
  signal are only exposed after successful auth.
- Session-count cap re-checked after bcrypt.hash so concurrent creates can't
  overshoot maxSessions.
- Relay web client reconnect backoff resets only after a successful auth
  response (an accept-then-close server no longer defeats the backoff).
- idGenerator comment corrected to match the rejection-sampling; drop unused
  recordFailure return value.
- DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jknapp merged commit e9b3108226 into main 2026-07-18 04:08:24 +00:00
jknapp deleted branch security/p0-rce-hardening 2026-07-18 04:08:24 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: MacroPad/MP-Server#6