From d07005bde304b7fb4fdef981f35454264067d81d Mon Sep 17 00:00:00 2001 From: MarcoPad Dev Date: Fri, 17 Jul 2026 18:07:48 -0700 Subject: [PATCH] security(relay): fix lockout DoS and DOM XSS in web client 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:: vs desktop::), 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) --- macropad-relay/public/app.html | 372 +--------------- macropad-relay/public/app.js | 405 ++++++++++++++++++ macropad-relay/public/login.html | 92 +--- macropad-relay/public/login.js | 91 ++++ macropad-relay/src/handlers/desktopHandler.ts | 20 +- .../src/handlers/webClientHandler.ts | 20 +- macropad-relay/src/server.ts | 13 +- macropad-relay/src/utils/authThrottle.ts | 12 +- 8 files changed, 543 insertions(+), 482 deletions(-) create mode 100644 macropad-relay/public/app.js create mode 100644 macropad-relay/public/login.js diff --git a/macropad-relay/public/app.html b/macropad-relay/public/app.html index d8fe592..2eacf99 100644 --- a/macropad-relay/public/app.html +++ b/macropad-relay/public/app.html @@ -264,8 +264,8 @@
- - + + @@ -277,372 +277,6 @@
- + diff --git a/macropad-relay/public/app.js b/macropad-relay/public/app.js new file mode 100644 index 0000000..6e61335 --- /dev/null +++ b/macropad-relay/public/app.js @@ -0,0 +1,405 @@ +// MacroPad App for Relay Mode (externalized so the CSP can drop +// 'unsafe-inline' for scripts). All macro/tab/id/name values are rendered +// with safe DOM APIs (createElement/textContent) and never via innerHTML. +class MacroPadApp { + constructor() { + this.macros = {}; + this.tabs = []; + this.currentTab = 'All'; + this.ws = null; + this.desktopConnected = false; + this.wsAuthenticated = false; + + // Get session ID from URL + const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/); + this.sessionId = pathMatch ? pathMatch[1] : null; + + // Get password from storage (never from the URL). + this.password = sessionStorage.getItem(`macropad_${this.sessionId}`) + || localStorage.getItem(`macropad_${this.sessionId}`); + + if (this.password) { + sessionStorage.setItem(`macropad_${this.sessionId}`, this.password); + } + + this.init(); + } + + async init() { + this.wakeLock = null; + this.wakeLockEnabled = false; + + this.setupPWA(); + this.setupWebSocket(); + this.setupEventListeners(); + this.setupWakeLock(); + } + + getApiHeaders() { + return { + 'Content-Type': 'application/json', + 'X-MacroPad-Password': this.password || '' + }; + } + + async loadTabs() { + try { + const response = await fetch(`/${this.sessionId}/api/tabs`, { + headers: this.getApiHeaders() + }); + if (response.status === 401) return this.handleAuthError(); + if (response.status === 503) return this.handleDesktopOffline(); + const data = await response.json(); + this.tabs = data.tabs || []; + this.renderTabs(); + } catch (error) { + console.error('Error loading tabs:', error); + } + } + + async loadMacros() { + try { + const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`; + const response = await fetch(`/${this.sessionId}${path}`, { + headers: this.getApiHeaders() + }); + if (response.status === 401) return this.handleAuthError(); + if (response.status === 503) return this.handleDesktopOffline(); + const data = await response.json(); + this.macros = data.macros || {}; + this.renderMacros(); + } catch (error) { + console.error('Error loading macros:', error); + } + } + + async executeMacro(macroId) { + const card = document.querySelector(`[data-macro-id="${macroId}"]`); + if (card) card.classList.add('executing'); + + try { + const response = await fetch(`/${this.sessionId}/api/execute`, { + method: 'POST', + headers: this.getApiHeaders(), + body: JSON.stringify({ macro_id: macroId }) + }); + if (!response.ok) throw new Error('Failed'); + } catch (error) { + this.showToast('Execution failed', 'error'); + } + + setTimeout(() => card?.classList.remove('executing'), 300); + } + + handleAuthError() { + sessionStorage.removeItem(`macropad_${this.sessionId}`); + localStorage.removeItem(`macropad_${this.sessionId}`); + window.location.href = `/${this.sessionId}`; + } + + handleDesktopOffline() { + this.desktopConnected = false; + this.updateConnectionStatus(false); + document.getElementById('offline-banner').classList.add('visible'); + } + + setupWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`; + + this.ws = new WebSocket(wsUrl); + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleMessage(data); + }; + + this.ws.onclose = () => { + this.wsAuthenticated = false; + this.updateConnectionStatus(false); + setTimeout(() => this.setupWebSocket(), 3000); + }; + + this.ws.onerror = () => this.updateConnectionStatus(false); + } + + handleMessage(data) { + switch (data.type) { + case 'auth_required': + if (this.password) { + this.ws.send(JSON.stringify({ type: 'auth', password: this.password })); + } + break; + case 'auth_response': + if (data.success) { + this.wsAuthenticated = true; + this.updateConnectionStatus(this.desktopConnected); + } else { + this.handleAuthError(); + } + break; + case 'desktop_status': + this.desktopConnected = data.status === 'connected'; + this.updateConnectionStatus(this.desktopConnected); + document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected); + if (this.desktopConnected) { + this.loadTabs(); + this.loadMacros(); + } + break; + case 'macro_created': + case 'macro_updated': + case 'macro_deleted': + this.loadTabs(); + this.loadMacros(); + break; + case 'executed': { + const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`); + if (card) { + card.classList.add('executing'); + setTimeout(() => card.classList.remove('executing'), 300); + } + break; + } + } + } + + updateConnectionStatus(connected) { + const dot = document.querySelector('.status-dot'); + const text = document.querySelector('.connection-status span'); + if (dot) dot.classList.toggle('connected', connected); + if (text) text.textContent = connected ? 'Connected' : 'Disconnected'; + } + + renderTabs() { + const container = document.getElementById('tabs-container'); + container.replaceChildren(); + for (const tab of this.tabs) { + const name = typeof tab === 'string' ? tab : String(tab); + const btn = document.createElement('button'); + btn.className = name === this.currentTab ? 'tab active' : 'tab'; + btn.setAttribute('data-tab', name); + btn.textContent = name; + container.appendChild(btn); + } + } + + renderMacros() { + const container = document.getElementById('macro-grid'); + const entries = Object.entries(this.macros); + + container.replaceChildren(); + + if (entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + const p = document.createElement('p'); + p.textContent = 'No macros found'; + empty.appendChild(p); + container.appendChild(empty); + return; + } + + for (const [id, macro] of entries) { + const name = macro && typeof macro.name === 'string' ? macro.name : ''; + const hasImage = !!(macro && macro.image_path); + const firstChar = name.charAt(0).toUpperCase(); + + const card = document.createElement('div'); + card.className = 'macro-card'; + card.setAttribute('data-macro-id', id); + card.addEventListener('click', () => this.executeMacro(id)); + + if (hasImage) { + const img = document.createElement('img'); + img.className = 'macro-image'; + img.style.display = 'none'; + card.appendChild(img); + } + + const placeholder = document.createElement('div'); + placeholder.className = 'macro-image-placeholder'; + placeholder.textContent = firstChar; + card.appendChild(placeholder); + + const nameSpan = document.createElement('span'); + nameSpan.className = 'macro-name'; + nameSpan.textContent = name; + card.appendChild(nameSpan); + + container.appendChild(card); + } + + // Load macro images via authenticated fetch (header, not URL). + this.loadMacroImages(); + } + + // Fetch each macro image with the password header and display it + // as a blob object URL so the credential never appears in a URL. + async loadMacroImages() { + for (const [id, macro] of Object.entries(this.macros)) { + if (!macro.image_path) continue; + const card = document.querySelector(`[data-macro-id="${id}"]`); + if (!card) continue; + const img = card.querySelector('.macro-image'); + if (!img) continue; + + try { + const response = await fetch( + `/${this.sessionId}/api/image/${macro.image_path}`, + { headers: this.getApiHeaders() } + ); + if (!response.ok) continue; // keep placeholder + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => URL.revokeObjectURL(objectUrl); + img.src = objectUrl; + img.style.display = ''; + const placeholder = img.nextElementSibling; + if (placeholder) placeholder.style.display = 'none'; + } catch (error) { + // Leave the placeholder visible on failure. + } + } + } + + setupEventListeners() { + document.getElementById('tabs-container').addEventListener('click', (e) => { + if (e.target.classList.contains('tab')) { + this.currentTab = e.target.dataset.tab; + this.renderTabs(); + this.loadMacros(); + } + }); + + const fullscreenBtn = document.getElementById('fullscreen-btn'); + if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); + + const refreshBtn = document.getElementById('refresh-btn'); + if (refreshBtn) refreshBtn.addEventListener('click', () => this.refresh()); + } + + showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.remove(), 3000); + } + + refresh() { + this.loadTabs(); + this.loadMacros(); + } + + // Fullscreen + toggleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(err => { + console.log('Fullscreen error:', err); + }); + } else { + document.exitFullscreen(); + } + } + + // Wake Lock + async setupWakeLock() { + const status = document.getElementById('wake-lock-status'); + + if (!('wakeLock' in navigator)) { + console.log('Wake Lock API not supported'); + if (status) { + status.classList.add('unsupported'); + status.title = 'Wake lock not available (requires HTTPS)'; + } + return; + } + + if (status) { + status.style.cursor = 'pointer'; + status.addEventListener('click', () => this.toggleWakeLock()); + } + + await this.requestWakeLock(); + + document.addEventListener('visibilitychange', async () => { + if (document.visibilityState === 'visible' && this.wakeLockEnabled) { + await this.requestWakeLock(); + } + }); + } + + async toggleWakeLock() { + if (this.wakeLock) { + await this.wakeLock.release(); + this.wakeLock = null; + this.wakeLockEnabled = false; + this.updateWakeLockStatus(false); + this.showToast('Screen can now sleep', 'info'); + } else { + this.wakeLockEnabled = true; + await this.requestWakeLock(); + if (this.wakeLock) { + this.showToast('Screen will stay awake', 'success'); + } + } + } + + async requestWakeLock() { + try { + this.wakeLock = await navigator.wakeLock.request('screen'); + this.wakeLockEnabled = true; + this.updateWakeLockStatus(true); + + this.wakeLock.addEventListener('release', () => { + this.updateWakeLockStatus(false); + }); + } catch (err) { + console.log('Wake Lock error:', err); + this.updateWakeLockStatus(false); + const status = document.getElementById('wake-lock-status'); + if (status && !status.classList.contains('unsupported')) { + status.title = 'Wake lock failed: ' + err.message; + } + } + } + + updateWakeLockStatus(active) { + const status = document.getElementById('wake-lock-status'); + if (status) { + status.classList.toggle('active', active); + if (!status.classList.contains('unsupported')) { + status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)'; + } + } + } + + // PWA manifest setup + setupPWA() { + // Create dynamic manifest for this session + const manifest = { + name: 'MacroPad', + short_name: 'MacroPad', + description: 'Remote macro control', + start_url: `/${this.sessionId}/app`, + display: 'standalone', + background_color: '#2e2e2e', + theme_color: '#007acc', + icons: [ + { src: '/static/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/static/icons/icon-512.png', sizes: '512x512', type: 'image/png' } + ] + }; + + const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' }); + const manifestUrl = URL.createObjectURL(blob); + document.getElementById('manifest-link').setAttribute('href', manifestUrl); + } +} + +let app; +document.addEventListener('DOMContentLoaded', () => { + app = new MacroPadApp(); +}); diff --git a/macropad-relay/public/login.html b/macropad-relay/public/login.html index 159ac6e..ab6397e 100644 --- a/macropad-relay/public/login.html +++ b/macropad-relay/public/login.html @@ -158,96 +158,6 @@

Checking connection...

- + diff --git a/macropad-relay/public/login.js b/macropad-relay/public/login.js new file mode 100644 index 0000000..dd224f5 --- /dev/null +++ b/macropad-relay/public/login.js @@ -0,0 +1,91 @@ +// MacroPad login page logic (externalized so the CSP can drop 'unsafe-inline' +// for scripts). +const sessionId = window.location.pathname.split('/')[1]; +const form = document.getElementById('loginForm'); +const passwordInput = document.getElementById('password'); +const rememberCheckbox = document.getElementById('remember'); +const submitBtn = document.getElementById('submitBtn'); +const errorDiv = document.getElementById('error'); +const statusDiv = document.getElementById('status'); + +let desktopConnected = false; + +// Check for saved password (session first, then "remembered" store) +const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`) + || localStorage.getItem(`macropad_${sessionId}`); +if (savedPassword) { + passwordInput.value = savedPassword; +} + +// Connect to WebSocket to check desktop status +function checkStatus() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'desktop_status') { + desktopConnected = data.status === 'connected'; + updateStatus(); + } + }; + + ws.onerror = () => { + statusDiv.textContent = 'Connection error'; + statusDiv.className = 'status disconnected'; + }; + + ws.onclose = () => { + setTimeout(checkStatus, 5000); + }; +} + +function updateStatus() { + if (desktopConnected) { + statusDiv.textContent = 'Desktop connected'; + statusDiv.className = 'status connected'; + submitBtn.disabled = false; + } else { + statusDiv.textContent = 'Desktop not connected'; + statusDiv.className = 'status disconnected'; + submitBtn.disabled = true; + } +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorDiv.style.display = 'none'; + + const password = passwordInput.value; + + try { + // Test password with a simple API call + const response = await fetch(`/${sessionId}/api/tabs`, { + headers: { + 'X-MacroPad-Password': password + } + }); + + if (response.ok) { + // Hand the credential to the app via storage (never the URL). + sessionStorage.setItem(`macropad_${sessionId}`, password); + if (rememberCheckbox.checked) { + localStorage.setItem(`macropad_${sessionId}`, password); + } else { + localStorage.removeItem(`macropad_${sessionId}`); + } + + // Redirect to the PWA (credential is read from storage). + window.location.href = `/${sessionId}/app`; + } else { + const data = await response.json(); + errorDiv.textContent = data.error || 'Invalid password'; + errorDiv.style.display = 'block'; + } + } catch (error) { + errorDiv.textContent = 'Connection failed'; + errorDiv.style.display = 'block'; + } +}); + +checkStatus(); diff --git a/macropad-relay/src/handlers/desktopHandler.ts b/macropad-relay/src/handlers/desktopHandler.ts index b6a4c7c..f7802fa 100644 --- a/macropad-relay/src/handlers/desktopHandler.ts +++ b/macropad-relay/src/handlers/desktopHandler.ts @@ -37,7 +37,8 @@ type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | Po export function handleDesktopConnection( socket: WebSocket, connectionManager: ConnectionManager, - sessionManager: SessionManager + sessionManager: SessionManager, + clientIp: string ): void { let authenticatedSessionId: string | null = null; // Track failed auth attempts on this individual socket. @@ -49,7 +50,7 @@ export function handleDesktopConnection( switch (message.type) { case 'auth': { - const failed = await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => { + const failed = await handleAuth(socket, message, sessionManager, connectionManager, clientIp, (sessionId) => { authenticatedSessionId = sessionId; }); if (failed) { @@ -110,6 +111,7 @@ async function handleAuth( message: AuthMessage, sessionManager: SessionManager, connectionManager: ConnectionManager, + clientIp: string, setSessionId: (id: string) => void ): Promise { try { @@ -117,8 +119,14 @@ async function handleAuth( let session; if (sessionId) { - // Reject early if this session id is currently locked out. - const lockRemaining = getLockoutRemaining(sessionId); + // Namespace the brute-force lockout key to the desktop keyspace and + // include the client IP. This keeps the desktop's lockout state entirely + // separate from the web-client keyspace, so a web-auth flood can never + // lock the desktop out of (re)authenticating. + const throttleKey = `desktop:${sessionId}:${clientIp}`; + + // Reject early if this key is currently locked out. + const lockRemaining = getLockoutRemaining(throttleKey); if (lockRemaining > 0) { socket.send(JSON.stringify({ type: 'auth_response', @@ -132,7 +140,7 @@ async function handleAuth( // Validate existing session const valid = await sessionManager.validatePassword(sessionId, message.password); if (!valid) { - recordFailure(sessionId); + recordFailure(throttleKey); socket.send(JSON.stringify({ type: 'auth_response', success: false, @@ -140,7 +148,7 @@ async function handleAuth( })); return true; } - recordSuccess(sessionId); + recordSuccess(throttleKey); session = sessionManager.getSession(sessionId); } else { // Create new session (may throw on limit / short password) diff --git a/macropad-relay/src/handlers/webClientHandler.ts b/macropad-relay/src/handlers/webClientHandler.ts index aa1c386..fd94bb5 100644 --- a/macropad-relay/src/handlers/webClientHandler.ts +++ b/macropad-relay/src/handlers/webClientHandler.ts @@ -25,8 +25,13 @@ export function handleWebClientConnection( socket: WebSocket, sessionId: string, connectionManager: ConnectionManager, - sessionManager: SessionManager + sessionManager: SessionManager, + clientIp: string ): void { + // Namespace the brute-force lockout key to the web keyspace and include the + // client IP. This prevents one abusive IP (which knows the shareable session + // id) from locking out other web clients or the desktop for this session. + const throttleKey = `web:${sessionId}:${clientIp}`; // Check if session exists const session = sessionManager.getSession(sessionId); if (!session) { @@ -62,7 +67,7 @@ export function handleWebClientConnection( switch (message.type) { case 'auth': { - const failed = await handleAuth(socket, client, message, sessionId, sessionManager); + const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey); if (failed) { socketAuthFailures++; if (socketAuthFailures >= config.authMaxSocketFailures) { @@ -106,10 +111,11 @@ async function handleAuth( client: WebClientConnection, message: AuthMessage, sessionId: string, - sessionManager: SessionManager + sessionManager: SessionManager, + throttleKey: string ): Promise { - // Reject early if this session id is currently locked out. - const lockRemaining = getLockoutRemaining(sessionId); + // Reject early if this key is currently locked out. + const lockRemaining = getLockoutRemaining(throttleKey); if (lockRemaining > 0) { socket.send(JSON.stringify({ type: 'auth_response', @@ -123,7 +129,7 @@ async function handleAuth( const valid = await sessionManager.validatePassword(sessionId, message.password); if (valid) { - recordSuccess(sessionId); + recordSuccess(throttleKey); client.authenticated = true; socket.send(JSON.stringify({ type: 'auth_response', @@ -132,7 +138,7 @@ async function handleAuth( logger.debug(`Web client authenticated for session: ${sessionId}`); return false; } else { - recordFailure(sessionId); + recordFailure(throttleKey); socket.send(JSON.stringify({ type: 'auth_response', success: false, diff --git a/macropad-relay/src/server.ts b/macropad-relay/src/server.ts index a8917b5..a576bfb 100644 --- a/macropad-relay/src/server.ts +++ b/macropad-relay/src/server.ts @@ -89,12 +89,15 @@ export function createServer() { // Middleware app.use(helmet({ - // The bundled login/app pages rely on inline