fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
delegated data-action handler; remote MDI SVGs parsed and sanitized
(strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent
Bugs
- WebSocket reconnect: close previous socket before opening new, clear
ping interval per-socket, clear reconnectTimeout up-front, retry on
online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip
Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
— cache grew unbounded)
- Progress drag listeners attached only while dragging
Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
_upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
pip install -e . users see the source-of-truth version, not the stale
package-metadata version baked in at install time
UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
callbacks.empty in both en + ru
This commit is contained in:
@@ -155,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
// Shared state (accessed across multiple modules)
|
||||
export let ws = null;
|
||||
export function setWs(value) { ws = value; }
|
||||
export function getWs() { return ws; }
|
||||
export let currentState = 'idle';
|
||||
export function setCurrentState(value) { currentState = value; }
|
||||
export let currentDuration = 0;
|
||||
@@ -513,6 +514,12 @@ export function setVolume(volume) {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
}
|
||||
// Reset the de-dupe cache whenever the server reports a fresh volume value
|
||||
// (e.g., another client moved the slider). Otherwise the user can end up
|
||||
// unable to "set volume back to the value we last sent" after a remote change.
|
||||
export function notifyRemoteVolume(volume) {
|
||||
lastSentVolume = volume;
|
||||
}
|
||||
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
@@ -536,23 +543,68 @@ function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
// Strict iconify MDI slug — used to reject anything that could be path-traversal
|
||||
// or query injection before we even hit the network.
|
||||
const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
function sanitizeSvg(rawSvg) {
|
||||
// Parse the SVG and strip anything that could execute script. Anything
|
||||
// unparseable returns null so callers fall back to the placeholder.
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) {
|
||||
return null;
|
||||
}
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
const toRemove = [];
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
if (tag === 'script' || tag === 'foreignobject') {
|
||||
toRemove.push(node);
|
||||
} else if (node.attributes) {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on') ||
|
||||
((name === 'href' || name === 'xlink:href') &&
|
||||
/^\s*(javascript|data):/i.test(attr.value))) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
toRemove.forEach((el) => el.remove());
|
||||
return root.outerHTML;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PLACEHOLDER_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
const name = String(iconName || '').replace(/^mdi:/, '');
|
||||
if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG;
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`);
|
||||
if (response.ok) {
|
||||
const svg = await response.text();
|
||||
mdiIconCache[name] = svg;
|
||||
_persistMdiCache();
|
||||
return svg;
|
||||
const raw = await response.text();
|
||||
const safe = sanitizeSvg(raw);
|
||||
if (safe) {
|
||||
mdiIconCache[name] = safe;
|
||||
_persistMdiCache();
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch MDI icon:', name, e);
|
||||
}
|
||||
|
||||
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
return PLACEHOLDER_SVG;
|
||||
}
|
||||
|
||||
export async function resolveMdiIcons(container) {
|
||||
|
||||
Reference in New Issue
Block a user