Security Fixes
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"bundle:novnc": "node scripts/bundle-novnc.js",
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"test": "node scripts/test-regressions.js",
|
||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const repo = path.resolve(root, "..");
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(repo, rel), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(file, needle, message) {
|
||||
const text = read(file);
|
||||
if (!text.includes(needle)) {
|
||||
throw new Error(`${message}\nMissing ${JSON.stringify(needle)} in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotContains(file, needle, message) {
|
||||
const text = read(file);
|
||||
if (text.includes(needle)) {
|
||||
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"escapeHtml(title)",
|
||||
"file-drop prompt titles must escape filenames containing HTML characters",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"SFTPUploadStart",
|
||||
"drag-drop upload must use chunked SFTP streaming",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"SFTPUploadChunk",
|
||||
"drag-drop upload must send bounded chunks",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"Use SFTP Upload for files over 25 MB",
|
||||
"drag-drop upload must not reject files only because they exceed the old inline limit",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/app.js",
|
||||
"confirmDirectVNCProfile",
|
||||
"direct VNC connections must show an explicit warning",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/profiles.js",
|
||||
"skipDirectVNCWarning",
|
||||
"save-and-connect should not show duplicate direct VNC prompts",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/recordings.js",
|
||||
"import-status",
|
||||
"profile import failures must be visible inside the import modal",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/recordings.js",
|
||||
"_formatImportError",
|
||||
"profile import failures must be formatted without using HTML injection",
|
||||
);
|
||||
assertContains(
|
||||
"internal/api/api_test.go",
|
||||
"TestCreateProfileRejectsUnsafeGitHubBaseURL",
|
||||
"GitHub HTTP/HTTPS URL validation must stay covered",
|
||||
);
|
||||
|
||||
console.log("frontend regression checks passed");
|
||||
@@ -116,6 +116,19 @@
|
||||
border-color: #e53935;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.btn-warning {
|
||||
background: var(--warning);
|
||||
color: #1a1a1e;
|
||||
border-color: var(--warning);
|
||||
}
|
||||
.btn-warning:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.btn-warning:active {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.btn-sm {
|
||||
height: 26px;
|
||||
|
||||
@@ -18,6 +18,34 @@
|
||||
}
|
||||
|
||||
/* Recordings list in browser modal */
|
||||
.recording-storage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 0 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border, #3f3f46);
|
||||
}
|
||||
|
||||
.recording-storage-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
}
|
||||
|
||||
.recording-cleanup-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recording-cleanup-status {
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.recordings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -66,6 +94,12 @@
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.recording-cleanup-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback modal — larger to fit a terminal */
|
||||
.modal-playback {
|
||||
width: 90vw;
|
||||
|
||||
+80
-2
@@ -744,7 +744,7 @@
|
||||
* Opens a new session for the given profile ID.
|
||||
* @param {string} profileId
|
||||
*/
|
||||
async connectProfile(profileId) {
|
||||
async connectProfile(profileId, options = {}) {
|
||||
const profile = this.state.getProfile(profileId);
|
||||
if (!profile) {
|
||||
this.notifications.error("Profile not found");
|
||||
@@ -774,6 +774,14 @@
|
||||
if (decision !== "new") return;
|
||||
}
|
||||
|
||||
if (
|
||||
!options.skipDirectVNCWarning &&
|
||||
this._requiresDirectVNCWarning(profile)
|
||||
) {
|
||||
const ok = await this.confirmDirectVNCProfile(profile, "connect");
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo =
|
||||
await window.go.api.Service.ConnectSession(profileId);
|
||||
@@ -977,7 +985,9 @@
|
||||
*/
|
||||
_confirmReuseSession(profile, session) {
|
||||
const createElement = window.NexusTerminal.createElement;
|
||||
const label = (profile.displayName || profile.host || "Untitled").trim();
|
||||
const label = window.NexusTerminal.escapeHtml(
|
||||
(profile.displayName || profile.host || "Untitled").trim(),
|
||||
);
|
||||
const stateLabel =
|
||||
session.state === "connected" ? "connected" : "connecting";
|
||||
|
||||
@@ -1033,6 +1043,74 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a profile connects directly to VNC without SSH tunnelling.
|
||||
* @param {Object} profile
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_requiresDirectVNCWarning(profile) {
|
||||
return !!(
|
||||
profile &&
|
||||
profile.protocol === "vnc" &&
|
||||
profile.vnc &&
|
||||
!profile.vnc.sshTunnel
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns before saving or connecting direct VNC profiles.
|
||||
* @param {Object} profile
|
||||
* @param {"save"|"connect"} action
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
confirmDirectVNCProfile(profile, action) {
|
||||
const { createElement, createModalShell } = window.NexusTerminal;
|
||||
const label = (
|
||||
profile.displayName ||
|
||||
profile.vnc?.vncHost ||
|
||||
profile.host ||
|
||||
"VNC"
|
||||
).trim();
|
||||
const actionLabel = action === "connect" ? "Connect" : "Save";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const shell = createModalShell("Direct VNC Connection", "modal-sm");
|
||||
const bodyText = createElement("p", "", {
|
||||
textContent:
|
||||
label +
|
||||
" is configured without an SSH tunnel. VNC traffic may be visible to the network unless the server is otherwise protected.",
|
||||
});
|
||||
bodyText.style.fontSize = "var(--text-sm)";
|
||||
bodyText.style.color = "var(--text-secondary)";
|
||||
shell.body.appendChild(bodyText);
|
||||
|
||||
const cancelBtn = createElement("button", "btn btn-sm", {
|
||||
textContent: "Cancel",
|
||||
});
|
||||
const okBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-warning direct-vnc-confirm",
|
||||
{ textContent: actionLabel },
|
||||
);
|
||||
shell.footer.appendChild(cancelBtn);
|
||||
shell.footer.appendChild(okBtn);
|
||||
|
||||
const close = (value) => {
|
||||
shell.remove();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
shell.closeButton.addEventListener("click", () => close(false));
|
||||
cancelBtn.addEventListener("click", () => close(false));
|
||||
okBtn.addEventListener("click", () => close(true));
|
||||
shell.backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === shell.backdrop) close(false);
|
||||
});
|
||||
shell.show();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Import / Export ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -2314,6 +2314,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.app._requiresDirectVNCWarning &&
|
||||
this.app._requiresDirectVNCWarning(this._profile)
|
||||
) {
|
||||
const ok = await this.app.confirmDirectVNCProfile(
|
||||
this._profile,
|
||||
andConnect ? "connect" : "save",
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
let saved;
|
||||
if (this._isNew) {
|
||||
@@ -2329,7 +2340,7 @@
|
||||
this.close();
|
||||
|
||||
if (andConnect && saved && saved.id) {
|
||||
this.app.connectProfile(saved.id);
|
||||
this.app.connectProfile(saved.id, { skipDirectVNCWarning: true });
|
||||
}
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Save failed: " + err);
|
||||
|
||||
@@ -70,8 +70,11 @@ class RecordingManager {
|
||||
*/
|
||||
async openBrowser() {
|
||||
try {
|
||||
const recordings = await window.go.api.Service.ListRecordings();
|
||||
this._showBrowserModal(recordings || []);
|
||||
const [recordings, storageInfo] = await Promise.all([
|
||||
window.go.api.Service.ListRecordings(),
|
||||
window.go.api.Service.GetRecordingStorageInfo(),
|
||||
]);
|
||||
this._showBrowserModal(recordings || [], storageInfo || {});
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to list recordings: ' + err);
|
||||
}
|
||||
@@ -133,9 +136,10 @@ class RecordingManager {
|
||||
/**
|
||||
* Renders a modal listing all saved recordings.
|
||||
* @param {Array} recordings
|
||||
* @param {Object} storageInfo
|
||||
* @private
|
||||
*/
|
||||
_showBrowserModal(recordings) {
|
||||
_showBrowserModal(recordings, storageInfo = {}) {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
|
||||
@@ -164,12 +168,36 @@ class RecordingManager {
|
||||
listHTML += '</div>';
|
||||
}
|
||||
|
||||
const totalSize = _formatBytes(storageInfo.totalBytes || 0);
|
||||
const count = storageInfo.count ?? recordings.length;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Session Recordings</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
|
||||
<div class="recording-storage">
|
||||
<div class="recording-storage-summary">
|
||||
<span>${count} recording${count === 1 ? '' : 's'}</span>
|
||||
<span>${totalSize}</span>
|
||||
</div>
|
||||
<div class="recording-cleanup-controls">
|
||||
<select class="form-input recording-cleanup-age">
|
||||
<option value="30">Older than 30 days</option>
|
||||
<option value="7">Older than 7 days</option>
|
||||
<option value="90">Older than 90 days</option>
|
||||
<option value="0">Ignore age</option>
|
||||
</select>
|
||||
<select class="form-input recording-cleanup-size">
|
||||
<option value="1073741824">Keep under 1 GB</option>
|
||||
<option value="5368709120">Keep under 5 GB</option>
|
||||
<option value="0">Ignore size</option>
|
||||
</select>
|
||||
<button class="btn btn-sm recording-cleanup">Clean up</button>
|
||||
</div>
|
||||
<div class="form-help recording-cleanup-status"></div>
|
||||
</div>
|
||||
${listHTML}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -189,6 +217,26 @@ class RecordingManager {
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.modal-close-btn').addEventListener('click', close);
|
||||
|
||||
modal.querySelector('.recording-cleanup').addEventListener('click', async () => {
|
||||
const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0;
|
||||
const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0;
|
||||
const status = modal.querySelector('.recording-cleanup-status');
|
||||
const btn = modal.querySelector('.recording-cleanup');
|
||||
status.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await window.go.api.Service.CleanupRecordings(ageDays, maxBytes);
|
||||
status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`;
|
||||
const fresh = await window.go.api.Service.ListRecordings();
|
||||
close();
|
||||
this._showBrowserModal(fresh || [], await window.go.api.Service.GetRecordingStorageInfo());
|
||||
} catch (err) {
|
||||
status.textContent = 'Cleanup failed: ' + err;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Play buttons
|
||||
modal.querySelectorAll('.rec-play').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -545,6 +593,7 @@ class ExportImportUI {
|
||||
<option value="duplicate">Create duplicates</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-error import-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm import-cancel">Cancel</button>
|
||||
@@ -567,13 +616,18 @@ class ExportImportUI {
|
||||
modal.querySelector('.import-confirm').addEventListener('click', async () => {
|
||||
const password = modal.querySelector('.import-password').value;
|
||||
const strategy = modal.querySelector('.import-strategy').value;
|
||||
const status = modal.querySelector('.import-status');
|
||||
const confirmBtn = modal.querySelector('.import-confirm');
|
||||
status.textContent = '';
|
||||
|
||||
if (!password) {
|
||||
this.app.notifications.error('Please enter the decryption password.');
|
||||
status.textContent = 'Please enter the decryption password.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Importing...';
|
||||
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
|
||||
this.app.notifications.success(`Imported ${count} profile(s)`);
|
||||
await this.app.loadProfiles();
|
||||
@@ -582,10 +636,19 @@ class ExportImportUI {
|
||||
if (String(err).includes('cancelled')) {
|
||||
return;
|
||||
}
|
||||
this.app.notifications.error('Import failed: ' + err);
|
||||
status.textContent = this._formatImportError(err);
|
||||
this.app.notifications.error('Import failed');
|
||||
} finally {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Import';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_formatImportError(err) {
|
||||
const msg = String(err || 'Unknown import error');
|
||||
return msg.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+42
-25
@@ -7,7 +7,7 @@
|
||||
|
||||
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
|
||||
|
||||
const MAX_INLINE_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
const UPLOAD_CHUNK_BYTES = 768 * 1024;
|
||||
|
||||
/**
|
||||
* Encode a string as UTF-8 bytes then base64-encode them.
|
||||
@@ -828,31 +828,8 @@
|
||||
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
if (typeof file.size === "number" && file.size > MAX_INLINE_UPLOAD_BYTES) {
|
||||
this.app.notifications.error(
|
||||
"Upload failed for " +
|
||||
file.name +
|
||||
": file is too large for drag-and-drop upload. Use SFTP Upload for files over 25 MB.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await this._readFileBuffer(file);
|
||||
if (buffer && buffer.byteLength > MAX_INLINE_UPLOAD_BYTES) {
|
||||
this.app.notifications.error(
|
||||
"Upload failed for " +
|
||||
file.name +
|
||||
": file is too large for drag-and-drop upload. Use SFTP Upload for files over 25 MB.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const base64Data = this._arrayBufferToBase64(buffer);
|
||||
await window.go.api.Service.SFTPUploadData(
|
||||
sessionId,
|
||||
remoteDir,
|
||||
file.name,
|
||||
base64Data,
|
||||
);
|
||||
await this._uploadDroppedFileStream(sessionId, remoteDir, file);
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
this.app.notifications.error(
|
||||
@@ -875,6 +852,46 @@
|
||||
});
|
||||
}
|
||||
|
||||
async _uploadDroppedFileStream(sessionId, remoteDir, file) {
|
||||
const totalBytes = typeof file.size === "number" ? file.size : 0;
|
||||
let uploadId = "";
|
||||
try {
|
||||
uploadId = await window.go.api.Service.SFTPUploadStart(
|
||||
sessionId,
|
||||
remoteDir,
|
||||
file.name,
|
||||
totalBytes,
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < totalBytes; offset += UPLOAD_CHUNK_BYTES) {
|
||||
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes);
|
||||
const buffer = await this._readFileSlice(file, offset, end);
|
||||
const base64Data = this._arrayBufferToBase64(buffer);
|
||||
await window.go.api.Service.SFTPUploadChunk(uploadId, base64Data);
|
||||
}
|
||||
|
||||
await window.go.api.Service.SFTPUploadFinish(uploadId);
|
||||
} catch (err) {
|
||||
if (uploadId) {
|
||||
try {
|
||||
await window.go.api.Service.SFTPUploadCancel(uploadId);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_readFileSlice(file, start, end) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error("read failed"));
|
||||
reader.readAsArrayBuffer(file.slice(start, end));
|
||||
});
|
||||
}
|
||||
|
||||
_readFileBuffer(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -52,6 +52,52 @@
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard modal skeleton without HTML string interpolation.
|
||||
* @param {string} title
|
||||
* @param {string} [sizeClass]
|
||||
* @returns {{backdrop: HTMLElement, modal: HTMLElement, body: HTMLElement, footer: HTMLElement, closeButton: HTMLElement, show: Function, remove: Function}}
|
||||
*/
|
||||
function createModalShell(title, sizeClass = "modal-sm") {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", `modal ${sizeClass}`);
|
||||
const header = createElement("div", "modal-header");
|
||||
const titleEl = createElement("h3", "modal-title", { textContent: title });
|
||||
const closeButton = createElement("button", "btn btn-icon btn-xs modal-close", {
|
||||
textContent: "x",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
const body = createElement("div", "modal-body");
|
||||
const footer = createElement("div", "modal-footer");
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(closeButton);
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
return {
|
||||
backdrop,
|
||||
modal,
|
||||
body,
|
||||
footer,
|
||||
closeButton,
|
||||
show() {
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - Raw string.
|
||||
@@ -305,6 +351,7 @@
|
||||
$,
|
||||
$$,
|
||||
createElement,
|
||||
createModalShell,
|
||||
escapeHtml,
|
||||
formatTimestamp,
|
||||
debounce,
|
||||
|
||||
Reference in New Issue
Block a user