mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
Harden browser storage for network drafts
This commit is contained in:
1 parent
b2e0719d4b
commit
85fb85b032
6 files changed
+197
-21
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
Reference in new issue
Block a user