From 85ba1712e432b69ece592a0b4fc5a94e671f4f15 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Fri, 15 May 2026 14:03:05 -0500 Subject: [PATCH] Harden browser storage for network drafts (cherry picked from commit 85fb85b032384db165c8cb8ad44307024ecdd659) --- SECURITY_AUDIT_1_TASKS.md | 2 + .../src/config/network_mode_storage.mjs | 73 +++++++++++++ .../src/config/network_mode_storage.test.mjs | 103 ++++++++++++++++++ .../js_build/src/config_interface.js | 37 +++---- .../src/node_manager/js_build/src/template.js | 2 + .../js_build/test-node-manager.sh | 1 + 6 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.mjs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.test.mjs diff --git a/SECURITY_AUDIT_1_TASKS.md b/SECURITY_AUDIT_1_TASKS.md index 8f38b73c..30fe585d 100644 --- a/SECURITY_AUDIT_1_TASKS.md +++ b/SECURITY_AUDIT_1_TASKS.md @@ -180,6 +180,8 @@ Done when: ### 7. Move sensitive browser operational drafts out of durable localStorage +Status: Done. Network-mode drafts and pending-operation state now use tab-scoped `sessionStorage`, legacy durable values are migrated and removed, apply/confirm/revert/rollback/logout clear operational keys, harmless preferences remain durable, and node_manager storage/build-contract validation passed on 2026-05-15. + Why: The update audit found no durable session tokens in browser storage, which is good. Remaining `localStorage` usage still includes operational drafts and pending network-mode state. On shared operator workstations, A-grade posture should reduce how much deployment detail survives logout or browser restart. diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.mjs b/src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.mjs new file mode 100644 index 00000000..ce9eb8c7 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.mjs @@ -0,0 +1,73 @@ +export const DRAFT_KEY = "lqos-network-mode-draft"; +export const PENDING_OPERATION_KEY = "lqos-network-mode-pending"; + +const OPERATIONAL_NETWORK_MODE_KEYS = [ + DRAFT_KEY, + PENDING_OPERATION_KEY, +]; + +function storageOf(kind) { + try { + return globalThis.window?.[kind] ?? globalThis[kind] ?? null; + } catch (_) { + return null; + } +} + +function readJson(storage, key) { + if (!storage) return null; + try { + const raw = storage.getItem(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (_) { + return null; + } +} + +export function loadNetworkModeState(key) { + const session = storageOf("sessionStorage"); + const value = readJson(session, key); + if (value !== null) return value; + + const local = storageOf("localStorage"); + const legacyValue = readJson(local, key); + if (legacyValue !== null && session) { + try { + session.setItem(key, JSON.stringify(legacyValue)); + } catch (_) {} + } + if (local) { + try { + local.removeItem(key); + } catch (_) {} + } + return legacyValue; +} + +export function saveNetworkModeState(key, value) { + const session = storageOf("sessionStorage"); + if (session) { + session.setItem(key, JSON.stringify(value)); + } + removeLegacyNetworkModeState(key); +} + +export function clearNetworkModeState(key) { + const session = storageOf("sessionStorage"); + if (session) { + session.removeItem(key); + } + removeLegacyNetworkModeState(key); +} + +export function clearOperationalNetworkModeStorage() { + OPERATIONAL_NETWORK_MODE_KEYS.forEach(clearNetworkModeState); +} + +function removeLegacyNetworkModeState(key) { + const local = storageOf("localStorage"); + if (local) { + local.removeItem(key); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.test.mjs new file mode 100644 index 00000000..45929494 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/network_mode_storage.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearOperationalNetworkModeStorage, + DRAFT_KEY, + loadNetworkModeState, + PENDING_OPERATION_KEY, + saveNetworkModeState, +} from "./network_mode_storage.mjs"; + +class FakeStorage { + constructor(entries = {}) { + this.values = new Map(Object.entries(entries)); + } + + getItem(key) { + return this.values.has(key) ? this.values.get(key) : null; + } + + setItem(key, value) { + this.values.set(key, String(value)); + } + + removeItem(key) { + this.values.delete(key); + } +} + +function withStorage({localEntries = {}, sessionEntries = {}}, callback) { + const previousWindow = globalThis.window; + const localStorage = new FakeStorage(localEntries); + const sessionStorage = new FakeStorage(sessionEntries); + + globalThis.window = {localStorage, sessionStorage}; + try { + callback({localStorage, sessionStorage}); + } finally { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + } +} + +test("saves network mode draft state only in tab storage", () => { + withStorage({ + localEntries: { + [DRAFT_KEY]: JSON.stringify({mode: "legacy"}), + }, + }, ({localStorage, sessionStorage}) => { + saveNetworkModeState(DRAFT_KEY, {mode: "bridge"}); + + assert.deepEqual(loadNetworkModeState(DRAFT_KEY), {mode: "bridge"}); + assert.equal(sessionStorage.getItem(DRAFT_KEY), JSON.stringify({mode: "bridge"})); + assert.equal(localStorage.getItem(DRAFT_KEY), null); + }); +}); + +test("migrates existing localStorage drafts into tab storage once", () => { + withStorage({ + localEntries: { + [DRAFT_KEY]: JSON.stringify({mode: "single_interface", interface: "eth0"}), + }, + }, ({localStorage, sessionStorage}) => { + assert.deepEqual(loadNetworkModeState(DRAFT_KEY), {mode: "single_interface", interface: "eth0"}); + assert.equal(sessionStorage.getItem(DRAFT_KEY), JSON.stringify({mode: "single_interface", interface: "eth0"})); + assert.equal(localStorage.getItem(DRAFT_KEY), null); + }); +}); + +test("drops malformed legacy network mode state", () => { + withStorage({ + localEntries: { + [PENDING_OPERATION_KEY]: "{not-json", + }, + }, ({localStorage, sessionStorage}) => { + assert.equal(loadNetworkModeState(PENDING_OPERATION_KEY), null); + assert.equal(sessionStorage.getItem(PENDING_OPERATION_KEY), null); + assert.equal(localStorage.getItem(PENDING_OPERATION_KEY), null); + }); +}); + +test("clears operational network mode state without touching durable preferences", () => { + withStorage({ + localEntries: { + [DRAFT_KEY]: JSON.stringify({mode: "legacy"}), + "lqos-theme": "dark", + }, + sessionEntries: { + [DRAFT_KEY]: JSON.stringify({mode: "bridge"}), + [PENDING_OPERATION_KEY]: JSON.stringify({operation_id: "op-1"}), + }, + }, ({localStorage, sessionStorage}) => { + clearOperationalNetworkModeStorage(); + + assert.equal(sessionStorage.getItem(DRAFT_KEY), null); + assert.equal(sessionStorage.getItem(PENDING_OPERATION_KEY), null); + assert.equal(localStorage.getItem(DRAFT_KEY), null); + assert.equal(localStorage.getItem("lqos-theme"), "dark"); + }); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js b/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js index 696b1d3e..833859c6 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_interface.js @@ -1,8 +1,13 @@ import {loadConfig, renderConfigMenu} from "./config/config_helper"; import {hydrateDraftMtu, MTU_MAX, MTU_MIN, parseOptionalMtu} from "./config/network_mode_mtu.mjs"; +import { + clearNetworkModeState, + DRAFT_KEY, + loadNetworkModeState, + PENDING_OPERATION_KEY, + saveNetworkModeState, +} from "./config/network_mode_storage.mjs"; -const DRAFT_KEY = "lqos-network-mode-draft"; -const PENDING_OPERATION_KEY = "lqos-network-mode-pending"; const NETPLAN_TRY_TIMEOUT_MS = 30_000; const RECONNECT_POLL_INTERVAL_MS = 2_000; const BRIDGE_MTU_HELP = "Applies to the bridge members and br0."; @@ -364,29 +369,17 @@ function populateFormFromConfig(config) { } function loadDraft() { - try { - const raw = localStorage.getItem(DRAFT_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch (_) { - return null; - } + return loadNetworkModeState(DRAFT_KEY); } function saveDraft() { const draft = buildCandidateConfig(); - localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + saveNetworkModeState(DRAFT_KEY, draft); return draft; } function loadPendingOperation() { - try { - const raw = localStorage.getItem(PENDING_OPERATION_KEY); - if (!raw) return null; - return JSON.parse(raw); - } catch (_) { - return null; - } + return loadNetworkModeState(PENDING_OPERATION_KEY); } function pendingDeadlineMs(operation) { @@ -421,14 +414,14 @@ function setPendingOperation(operation, extra = {}) { ...extra, saved_at_ms: Date.now(), }; - localStorage.setItem(PENDING_OPERATION_KEY, JSON.stringify(currentPendingOperation)); + saveNetworkModeState(PENDING_OPERATION_KEY, currentPendingOperation); renderRecoveryPanel(); ensureRecoveryPolling(); } function clearPendingOperation() { currentPendingOperation = null; - localStorage.removeItem(PENDING_OPERATION_KEY); + clearNetworkModeState(PENDING_OPERATION_KEY); if (reconnectPollTimer) { clearInterval(reconnectPollTimer); reconnectPollTimer = null; @@ -838,7 +831,7 @@ function applyNetworkChanges(mode = "Apply") { action_label: actionLabel, }); } - localStorage.removeItem(DRAFT_KEY); + clearNetworkModeState(DRAFT_KEY); window.config = candidate; renderRecoveryPanel(); window.scrollTo({ top: 0, behavior: "smooth" }); @@ -865,6 +858,7 @@ function actOnPending(endpoint) { postJson(endpoint, { operation_id: operationId }) .then((response) => { clearPendingOperation(); + clearNetworkModeState(DRAFT_KEY); alert(response?.message || "Operation completed"); return Promise.allSettled([refreshHelperStatus(), inspectCandidate()]); }) @@ -896,6 +890,7 @@ function rollbackLatestBackup() { postJson("/local-api/network-mode/rollback", { backup_id: backupId }) .then((response) => { + clearNetworkModeState(DRAFT_KEY); alert(response?.message || "Rollback completed"); return Promise.all([refreshHelperStatus(), inspectCandidate()]); }) @@ -960,7 +955,7 @@ function wireActions() { if (!validateConfig()) return; saveDraft(); inspectCandidate(); - alert("Network mode draft saved in this browser. Use Apply Network Changes to commit both lqos.conf and netplan together."); + alert("Network mode draft saved for this browser tab. Use Apply Network Changes to commit both lqos.conf and netplan together."); }); document.getElementById("inspectButton").addEventListener("click", inspectCandidate); document.getElementById("applyButton").addEventListener("click", () => applyNetworkChanges("Apply")); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index f556c064..1ba8df84 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -4,6 +4,7 @@ import {initDayNightMode} from "./helpers/dark_mode"; import {initColorBlind} from "./helpers/colorblind"; import {listenOnceMatchingWithTimeout as listenWsOnceMatchingWithTimeout} from "./pubsub/listeners"; import {get_ws_client} from "./pubsub/ws"; +import {clearOperationalNetworkModeStorage} from "./config/network_mode_storage.mjs"; const wsClient = get_ws_client(); const listenOnce = (eventName, handler) => { @@ -752,6 +753,7 @@ function getDeviceCounts() { function initLogout() { $("#btnLogout").on('click', () => { //console.log("Logout"); + clearOperationalNetworkModeStorage(); const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { diff --git a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh index b26d70bb..2a24d2f5 100755 --- a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh +++ b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh @@ -14,6 +14,7 @@ node --test \ "${SCRIPT_DIR}/src/circuit_packet_capture_dom.test.mjs" \ "${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \ "${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \ + "${SCRIPT_DIR}/src/config/network_mode_storage.test.mjs" \ "${SCRIPT_DIR}/src/config/ssl_redirect.test.mjs" \ "${SCRIPT_DIR}/src/pubsub/ws.test.mjs"