fix: harden dashboard auth and terminal flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m29s
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user