Harden browser storage for network drafts

This commit is contained in:
Herbert Wolverson committed 2026-06-14 14:38:46 -05:00
1 parent b2e0719d4b
commit 85fb85b032
6 files changed
+197 -21

No files matched your search

+2
View File
@@ -186,6 +186,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.
@@ -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);
}
}
@@ -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");
});
});
@@ -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"));
@@ -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++) {
@@ -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"