From 123da1b5c4baabe568a89f25436ee9b5b3997bc0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 04:56:04 +0300 Subject: [PATCH] fix: comprehensive security, stability, and code quality audit Security: - Force API key auth for LAN (non-loopback) requests; remove shipped dev key - Block path-traversal in backup restore; require auth on backup endpoints - SSRF protection: DNS resolve + private/loopback/link-local IP rejection - AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration - WebSocket auth migrated from query-string to first-message protocol - Asset upload: extension allowlist, server-side mime, Content-Disposition - Update installer: SHA256 verification, tar/zip member validation - Tightened CORS (explicit methods/headers, no credentials) - ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing Android: - Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping - USB permission blocking API via CompletableDeferred - Python init crash guard with fatal-error screen - Moved root grant + QR generation off Main thread - Cached PyObject engine for per-frame bridge calls - Ordered ScreenCapture resource cleanup, allowBackup=false Python: - Replaced all asyncio.get_event_loop() with get_running_loop/to_thread - Split color_strip_sources.py (1683->5 files) and color_strip_stream.py (1324->7 files) into packages - Extracted FrameLimiter utility, migrated 9 stream loops - Provider base-class reuse, WLED state caching + URL normalization - Narrowed broad except-pass in WS routes, threading fixes in BaseStore Frontend: - XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders - Typed DOM helpers, safe localStorage access, AbortController listener hygiene - openAuthedWs helper for first-message WS auth protocol - Migrated remaining plain with IconSelect + initLightbox(); + // Setup form handler const addDeviceForm = queryEl('add-device-form'); if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice); @@ -786,4 +796,12 @@ document.addEventListener('DOMContentLoaded', async () => { if (!localStorage.getItem('tour_completed')) { setTimeout(() => startGettingStartedTutorial(), 600); } + } catch (err) { + // Top-level init failure should not silently leave the user with a + // half-loaded UI — surface a toast and log a stack to DevTools. + console.error('App init failed:', err); + try { + showToast(`Init failed: ${err instanceof Error ? err.message : String(err)}`, 'error'); + } catch {/* showToast itself failed; nothing else to do */} + } }); diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 1debb52..fe62f95 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -66,6 +66,8 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P return resp; } catch (err) { clearTimeout(timer); + // Never retry auth errors — caller has already been notified by handle401Error. + if (err instanceof ApiError && err.status === 401) throw err; if (err instanceof ApiError) throw err; if (attempt < maxAttempts - 1) { await new Promise(r => setTimeout(r, 500 * 2 ** attempt)); @@ -86,11 +88,17 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P } // ── Cached metrics-history fetch ──────────────────────────── -let _metricsHistoryCache: { data: any; ts: number } | null = null; +/** + * Server response shape for /system/metrics-history. Kept loose because the + * payload contains many series and consumers narrow as needed. + */ +export type MetricsHistory = Record; + +let _metricsHistoryCache: { data: MetricsHistory; ts: number } | null = null; const _METRICS_CACHE_TTL = 5000; // 5 seconds /** Fetch metrics history with a short TTL cache to avoid duplicate requests across tabs. */ -export async function fetchMetricsHistory(): Promise { +export async function fetchMetricsHistory(): Promise { const now = Date.now(); if (_metricsHistoryCache && now - _metricsHistoryCache.ts < _METRICS_CACHE_TTL) { return _metricsHistoryCache.data; @@ -98,10 +106,12 @@ export async function fetchMetricsHistory(): Promise { try { const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); if (!resp.ok) return null; - const data = await resp.json(); + const data = await resp.json() as MetricsHistory; _metricsHistoryCache = { data, ts: now }; return data; - } catch { + } catch (err) { + // Best-effort cache fetch — surface in DevTools but don't pop a toast. + if (typeof console !== 'undefined') console.debug('[fetchMetricsHistory]', err); return null; } } diff --git a/server/src/ledgrab/static/js/core/bg-anim.ts b/server/src/ledgrab/static/js/core/bg-anim.ts index 166ff42..a9aa5b6 100644 --- a/server/src/ledgrab/static/js/core/bg-anim.ts +++ b/server/src/ledgrab/static/js/core/bg-anim.ts @@ -106,7 +106,9 @@ void main() { } `; -let _canvas: HTMLCanvasElement = undefined as any, _gl: WebGLRenderingContext | null = null, _prog: WebGLProgram | null = null; +let _canvas: HTMLCanvasElement | null = null; +let _gl: WebGLRenderingContext | null = null; +let _prog: WebGLProgram | null = null; let _uTime: WebGLUniformLocation | null, _uRes: WebGLUniformLocation | null, _uAccent: WebGLUniformLocation | null, _uBg: WebGLUniformLocation | null, _uLight: WebGLUniformLocation | null, _uParticlesBase: WebGLUniformLocation | null; let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv let _raf: number | null = null; @@ -156,6 +158,7 @@ function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLSh } function _initGL(): boolean { + if (!_canvas) return false; _gl = _canvas.getContext('webgl', { alpha: false, antialias: false, depth: false }); if (!_gl) return false; const gl = _gl; @@ -194,6 +197,7 @@ function _initGL(): boolean { } function _resize(): void { + if (!_canvas) return; const w = Math.round(window.innerWidth * 0.5); const h = Math.round(window.innerHeight * 0.5); _canvas.width = w; @@ -204,7 +208,7 @@ function _resize(): void { function _draw(time: number): void { _raf = requestAnimationFrame(_draw); const gl = _gl; - if (!gl) return; + if (!gl || !_canvas) return; _updateParticles(); diff --git a/server/src/ledgrab/static/js/core/bindable-color.ts b/server/src/ledgrab/static/js/core/bindable-color.ts index 067e708..735174a 100644 --- a/server/src/ledgrab/static/js/core/bindable-color.ts +++ b/server/src/ledgrab/static/js/core/bindable-color.ts @@ -11,6 +11,7 @@ import { bindableColor, bindableColorSourceId } from '../types.ts'; import { EntitySelect } from './entity-palette.ts'; import { getValueSourceIcon } from './icons.ts'; import { t } from './i18n.ts'; +import { getInput, getSelect, getButton, getEl, reconcileList } from './dom-helpers.ts'; export interface BindableColorOpts { container: HTMLElement; @@ -74,11 +75,11 @@ export class BindableColorWidget { this._container.innerHTML = colorHtml + vsHtml; - this._colorRow = document.getElementById(`${id}-color-row`)!; - this._vsRow = document.getElementById(`${id}-vs-row`)!; - this._colorInput = document.getElementById(`${id}-color`) as HTMLInputElement; - this._select = document.getElementById(`${id}-select`) as HTMLSelectElement; - this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement; + this._colorRow = getEl(`${id}-color-row`, HTMLElement); + this._vsRow = getEl(`${id}-vs-row`, HTMLElement); + this._colorInput = getInput(`${id}-color`); + this._select = getSelect(`${id}-select`); + this._toggleBtn = getButton(`${id}-toggle`); this._colorInput.addEventListener('input', () => { this._staticColor = this._hexToRgb(this._colorInput.value); @@ -86,7 +87,7 @@ export class BindableColorWidget { }); this._toggleBtn.addEventListener('click', () => this._setMode(true)); - document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false)); + getButton(`${id}-untoggle`).addEventListener('click', () => this._setMode(false)); } private _setMode(bound: boolean): void { @@ -106,10 +107,28 @@ export class BindableColorWidget { // Filter to only color value sources const sources = this._opts.valueSources().filter(vs => vs.return_type === 'color'); - this._select.innerHTML = `` + - sources.map(vs => - `` - ).join(''); + const noneLabel = this._opts.noneLabel || t('bindable.none'); + type Entry = { id: string; name: string }; + const entries: Entry[] = [{ id: '', name: noneLabel }, ...sources.map(vs => ({ id: vs.id, name: vs.name }))]; + + reconcileList( + this._select, + entries, + (e, idx) => `${idx}:${e.id}`, + (e) => { + const opt = document.createElement('option'); + opt.value = e.id; + opt.text = e.name; + return opt; + }, + (el, e) => { + if (el instanceof HTMLOptionElement) { + if (el.value !== e.id) el.value = e.id; + if (el.text !== e.name) el.text = e.name; + } + }, + ); + this._select.value = this._sourceId; if (this._entitySelect) this._entitySelect.destroy(); this._entitySelect = new EntitySelect({ diff --git a/server/src/ledgrab/static/js/core/bindable-scalar.ts b/server/src/ledgrab/static/js/core/bindable-scalar.ts index 0b8b9c7..daf8d67 100644 --- a/server/src/ledgrab/static/js/core/bindable-scalar.ts +++ b/server/src/ledgrab/static/js/core/bindable-scalar.ts @@ -22,6 +22,7 @@ import { bindableValue, bindableSourceId } from '../types.ts'; import { EntitySelect } from './entity-palette.ts'; import { getValueSourceIcon } from './icons.ts'; import { t } from './i18n.ts'; +import { getInput, getSelect, getButton, getEl, reconcileList } from './dom-helpers.ts'; export interface BindableScalarOpts { container: HTMLElement; @@ -97,13 +98,13 @@ export class BindableScalarWidget { this._container.innerHTML = sliderHtml + vsHtml; - // Cache DOM refs - this._sliderRow = document.getElementById(`${id}-slider-row`)!; - this._vsRow = document.getElementById(`${id}-vs-row`)!; - this._slider = document.getElementById(`${id}-slider`) as HTMLInputElement; - this._display = document.getElementById(`${id}-display`)!; - this._select = document.getElementById(`${id}-select`) as HTMLSelectElement; - this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement; + // Cache DOM refs (typed helpers throw if the markup is broken) + this._sliderRow = getEl(`${id}-slider-row`, HTMLElement); + this._vsRow = getEl(`${id}-vs-row`, HTMLElement); + this._slider = getInput(`${id}-slider`); + this._display = getEl(`${id}-display`, HTMLElement); + this._select = getSelect(`${id}-select`); + this._toggleBtn = getButton(`${id}-toggle`); // Slider input handler this._slider.addEventListener('input', () => { @@ -114,7 +115,7 @@ export class BindableScalarWidget { // Toggle to bound mode this._toggleBtn.addEventListener('click', () => this._setMode(true)); - document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false)); + getButton(`${id}-untoggle`).addEventListener('click', () => this._setMode(false)); } private _setMode(bound: boolean): void { @@ -136,12 +137,32 @@ export class BindableScalarWidget { private _populateVsSelect(): void { const sources = this._opts.valueSources(); - const id = this._id; - this._select.innerHTML = `` + - sources.map(vs => - `` - ).join(''); + // Reconcile