fix: harden dashboard auth and terminal flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m29s

Tighten terminal websocket auth and proxy trust handling while making file-backed auth/RBAC writes atomic to reduce high-impact security and persistence risks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-03-06 23:53:58 +08:00
parent c9f06fbbbb
commit 44f0ed5d04
9 changed files with 337 additions and 175 deletions
+93 -21
View File
@@ -120,6 +120,8 @@
function closeTab(id) {
const d = tabData.get(id);
if (d) {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy();
d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);
@@ -157,11 +159,30 @@
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token") || "";
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${tab.hostId}`
);
ws.binaryType = "arraybuffer";
function sendSize() {
let heartbeat = null;
let reconnectTimer = null;
let closedManually = false;
function clearHeartbeat() {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
}
function scheduleReconnect(reason) {
clearHeartbeat();
if (closedManually || reconnectTimer) return;
updateTab(tabId, { connected: false, error: `${reason} — reconnecting...` });
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!tabData.has(tabId) || closedManually) return;
connect();
}, 2000);
}
function sendSize(ws) {
if (ws.readyState === 1) {
const buf = new Uint8Array(5);
buf[0] = 0x01;
@@ -171,19 +192,62 @@
}
}
ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true, error: "" }); sendSize(); };
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onclose = (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`;
updateTab(tabId, { connected: false, error: reason });
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
};
ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" });
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
function connect() {
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
);
ws.binaryType = "arraybuffer";
const heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__");
}, 25000);
ws.onopen = () => {
updateTab(tabId, { connected: true, error: "" });
sendSize(ws);
clearHeartbeat();
heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__");
}, 25000);
const d = tabData.get(tabId);
if (d) {
d.ws = ws;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.voice?.attach(term, ws);
}
};
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onclose = (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`;
const d = tabData.get(tabId);
if (d) d.ws = null;
if (!closedManually) scheduleReconnect(reason);
else {
clearHeartbeat();
updateTab(tabId, { connected: false, error: reason });
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
}
};
ws.onerror = () => {
const d = tabData.get(tabId);
if (d) d.ws = null;
if (!closedManually) scheduleReconnect("Connection failed");
else updateTab(tabId, { connected: false, error: "Connection failed" });
};
const d = tabData.get(tabId);
if (d) {
d.ws = ws;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
}
return ws;
}
let ws = connect();
term.onData((data) => {
const currentWs = tabData.get(tabId)?.ws;
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
});
const handleDragOver = (e) => {
e.preventDefault();
@@ -192,19 +256,21 @@
const handleDrop = async (e) => {
e.preventDefault();
if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
for (const file of files) {
await sendDroppedImage(term, ws, file);
await sendDroppedImage(term, currentWs, file);
}
};
const handlePaste = async (e) => {
if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const items = [...(e.clipboardData?.items || [])]
.filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
for (const item of items) {
const file = item.getAsFile();
if (file) await sendDroppedImage(term, ws, file);
if (file) await sendDroppedImage(term, currentWs, file);
}
};
@@ -212,11 +278,15 @@
term.element?.addEventListener("drop", handleDrop);
term.textarea?.addEventListener("paste", handlePaste);
const ro = new ResizeObserver(() => { fit.fit(); sendSize(); });
const ro = new ResizeObserver(() => {
fit.fit();
const currentWs = tabData.get(tabId)?.ws;
if (currentWs) sendSize(currentWs);
});
ro.observe(el);
const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws);
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, handleDragOver, handleDrop, handlePaste });
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, handleDragOver, handleDrop, handlePaste });
})();
}
@@ -225,6 +295,8 @@
onDestroy(() => {
themeObserver.disconnect();
tabData.forEach((d) => {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy();
d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);