From 871d92dfc49764606008cc3629bc627f84c54ea3 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sat, 18 Jul 2026 14:22:16 -0700 Subject: [PATCH] fix: harden Dashboard hash routing against malformed and unknown tabs Three routing defects, all reachable from a shareable URL: - A malformed percent-sequence (e.g. `#100%`) made parseDashboardRoute's decodeURIComponent throw at module load, blanking the entire GUI. Guard the decode and fall back to the raw hash. Verified: `#100%` now boots. - A hash whose tab segment contained a quote (`#foo"bar`) was interpolated into a jQuery selector that throws, leaving dashboard event handlers unbound. Resolve the route tab against the known tab set (falling back to Apps) before it ever reaches a selector. This also fixes an unknown hash activating the Apps section without its `.dashboard-content.apps` styling. - Re-clicking the already-active sidebar tab pushed duplicate history entries, so Back became a no-op until pressed repeatedly. Only pushState when the hash actually changes. Verified live for all three. --- src/gui/src/UI/Dashboard/UIDashboard.js | 20 +++++++++++++++----- src/gui/src/initgui.js | 11 ++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index a7fa2484a..da9a563e3 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -349,13 +349,19 @@ async function UIDashboard (options) { } }); + // Resolve an untrusted route tab id (from the URL hash) to a known tab id, + // falling back to Apps. This keeps a crafted hash (e.g. one containing a + // quote) from being interpolated into a jQuery selector, which throws and + // would otherwise leave the dashboard's event handlers unbound. + const knownTabId = tab => (tabs.some(t => t !== '-' && t.id === tab) ? tab : 'apps'); + // Apply initial route from URL - activate the correct tab if ( window.dashboard_initial_route ) { const route = window.dashboard_initial_route; // Activate the correct tab if not home if ( route.tab && route.tab !== 'home' ) { - const tabId = route.tab; + const tabId = knownTabId(route.tab); const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tabId}"]`); // Only switch if the tab exists @@ -386,7 +392,7 @@ async function UIDashboard (options) { if ( window.location.href === lastHandledHref ) return; lastHandledHref = window.location.href; const route = window.parseDashboardRoute(); - const tab = route.tab; + const tab = knownTabId(route.tab); const filePath = route.path; // Switch to correct tab @@ -456,9 +462,13 @@ async function UIDashboard (options) { document.querySelector('.dashboard-content').classList.add(section); // Reflect the current tab in the hash. Root (no hash) defaults to Apps, - // but selecting any tab — including Apps — shows its #tab. - history.pushState(null, '', `#${section}`); - lastHandledHref = window.location.href; + // but selecting any tab — including Apps — shows its #tab. Only push a + // new history entry when the hash actually changes, so re-clicking the + // current tab doesn't stack duplicate entries that make Back a no-op. + if ( window.location.hash !== `#${section}` ) { + history.pushState(null, '', `#${section}`); + lastHandledHref = window.location.href; + } // Scroll content area to top $el_window.find('.dashboard-content').scrollTop(0); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index af63f8354..c82d67a1f 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -536,7 +536,16 @@ if (jQuery) { * @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path */ function parseDashboardRoute() { - const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding + // decodeURIComponent throws URIError on a malformed percent-sequence (e.g. + // `#100%`). This runs at module load, so an unguarded throw blanks the whole + // GUI — fall back to the raw hash instead. + const rawHash = window.location.hash.slice(1); // Remove '#' + let hash; + try { + hash = decodeURIComponent(rawHash); + } catch { + hash = rawHash; + } if (!hash) return { tab: 'apps', path: null }; const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents']