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.
This commit is contained in:
jelveh
2026-07-18 14:22:16 -07:00
parent 6c9c76ea57
commit 871d92dfc4
2 changed files with 25 additions and 6 deletions
+15 -5
View File
@@ -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);
+10 -1
View File
@@ -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']