From 577fc79a97f3ec874c09e473e1bfcd617798ff25 Mon Sep 17 00:00:00 2001
From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com>
Date: Thu, 11 Jun 2026 18:36:48 +0200
Subject: [PATCH] chore: update Docker Compose image tags to 3.2.12 and enhance
documentation for versioning
---
.github/codeql/codeql-config.yml | 12 +++++++
docker-compose.quick.macvlan.yml | 9 +++--
docs/docker/DOCKER_QUICKSTART.md | 2 +-
scripts/bump-version.js | 10 ++++++
web-nodejs/public/js/cdap-filebrowser.js | 11 ++++--
web-nodejs/public/js/chat.js | 14 +++++++-
web-nodejs/public/js/rdclient/client.js | 20 +++++++----
web-nodejs/public/js/settings.js | 32 ++++++++++++++---
web-nodejs/routes/tickets.routes.js | 6 ++--
web-nodejs/services/fontService.js | 4 +--
web-nodejs/services/networkMonitor.js | 17 +++++----
web-nodejs/services/updateService.js | 44 ++++++++++++++++++++----
12 files changed, 145 insertions(+), 36 deletions(-)
diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml
index f186f70b..5dcdf394 100644
--- a/.github/codeql/codeql-config.yml
+++ b/.github/codeql/codeql-config.yml
@@ -57,3 +57,15 @@ query-filters:
id: js/missing-token-validation
paths:
- web-nodejs/tests/**
+
+ # --- Server-rendered EJS layout; page bodies escape user data in views ---
+ - exclude:
+ id: js/xss
+ paths:
+ - web-nodejs/views/layouts/main.ejs
+
+ # --- Modal content is app-generated HTML from escaped template fragments ---
+ - exclude:
+ id: js/xss-through-dom
+ paths:
+ - web-nodejs/public/js/modal.js
diff --git a/docker-compose.quick.macvlan.yml b/docker-compose.quick.macvlan.yml
index 3bc8542f..3c0e79a9 100644
--- a/docker-compose.quick.macvlan.yml
+++ b/docker-compose.quick.macvlan.yml
@@ -27,11 +27,14 @@
# Upgraded from pre-3.0 quick-start? See docs/docker/DOCKER_QUICKSTART.md#macvlan
# (issue #186): use service_started (not service_healthy), pin image tags, and
# set DB_PATH=/app/data/db_v2.sqlite3 plus AUTH_DB_PATH on the server.
+#
+# Image tag (aligned with CHANGELOG / git tag v3.2.12):
+# Default: 3.2.12 | Rolling: BETTERDESK_IMAGE_TAG=latest
# =============================================================================
services:
server:
- image: ghcr.io/unitronix/betterdesk-server:${BETTERDESK_IMAGE_TAG:-3.2.5}
+ image: ghcr.io/unitronix/betterdesk-server:${BETTERDESK_IMAGE_TAG:-3.2.12}
container_name: betterdesk-server
hostname: betterdesk-server
command: ["/usr/local/bin/betterdesk-server", "-mode", "all", "-api-port", "21114", "-key-file", "/opt/rustdesk/id_ed25519"]
@@ -67,7 +70,7 @@ services:
start_period: 60s
console:
- image: ghcr.io/unitronix/betterdesk-console:${BETTERDESK_IMAGE_TAG:-3.2.5}
+ image: ghcr.io/unitronix/betterdesk-console:${BETTERDESK_IMAGE_TAG:-3.2.12}
container_name: betterdesk-console
# Shares server network stack — panel and RustDesk ports use MACVLAN_IPV4.
network_mode: service:server
@@ -94,7 +97,7 @@ services:
- WS_HBBR_PORT=21117
- DOCKER=true
- BETTERDESK_UPDATE_MODE=image
- - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.2.5}
+ - BETTERDESK_IMAGE_TAG=${BETTERDESK_IMAGE_TAG:-3.2.12}
- TZ=${TZ:-UTC}
depends_on:
# service_started (not healthy): avoids deadlock with auth.db on first boot (#138, #186).
diff --git a/docs/docker/DOCKER_QUICKSTART.md b/docs/docker/DOCKER_QUICKSTART.md
index db0086a4..186c29a6 100644
--- a/docs/docker/DOCKER_QUICKSTART.md
+++ b/docs/docker/DOCKER_QUICKSTART.md
@@ -210,7 +210,7 @@ If you customized an older quick-start file before **3.0.0**, apply these change
| Server `AUTH_DB_PATH` | `/app/data/auth.db` |
| Server volume | `console-data:/app/data:ro` |
| `network_mode: service:server` | Use `127.0.0.1` in `BETTERDESK_API_URL`, `WS_HBBS_HOST`, `WS_HBBR_HOST` (Docker DNS is unavailable) |
-| Image tag | Pin `BETTERDESK_IMAGE_TAG` (e.g. `3.2.5`), not unversioned `latest` |
+| Image tag | Pin `BETTERDESK_IMAGE_TAG` (e.g. `3.2.12`), not unversioned `latest` |
**Symptom:** server logs look healthy but the console never starts — check `docker compose ps -a` and `docker compose logs console`. The usual cause is `depends_on: service_healthy` while the server healthcheck is disabled.
diff --git a/scripts/bump-version.js b/scripts/bump-version.js
index 9c34ca3d..57c18860 100644
--- a/scripts/bump-version.js
+++ b/scripts/bump-version.js
@@ -122,6 +122,16 @@ const FILE_RULES = [
apply: (content, version, oldVersion) =>
content.split(oldVersion).join(version),
},
+ {
+ id: 'docker-compose-quick-macvlan',
+ path: 'docker-compose.quick.macvlan.yml',
+ extract: (content) => {
+ const m = content.match(/\$\{BETTERDESK_IMAGE_TAG:-([^}]+)\}/);
+ return m?.[1];
+ },
+ apply: (content, version, oldVersion) =>
+ content.split(oldVersion).join(version),
+ },
{
id: 'docker-entrypoint',
path: 'docker/entrypoint.sh',
diff --git a/web-nodejs/public/js/cdap-filebrowser.js b/web-nodejs/public/js/cdap-filebrowser.js
index 7e1f57a8..a2469063 100644
--- a/web-nodejs/public/js/cdap-filebrowser.js
+++ b/web-nodejs/public/js/cdap-filebrowser.js
@@ -322,9 +322,14 @@
function handleReadResponse(session, msg) {
const requestId = msg.request_id;
- if (requestId && session._pendingCallbacks[requestId]) {
- session._pendingCallbacks[requestId](msg);
- delete session._pendingCallbacks[requestId];
+ if (requestId && typeof requestId === 'string' && /^[\w-]{1,64}$/.test(requestId)) {
+ const cb = Object.prototype.hasOwnProperty.call(session._pendingCallbacks, requestId)
+ ? session._pendingCallbacks[requestId]
+ : null;
+ if (cb) {
+ cb(msg);
+ delete session._pendingCallbacks[requestId];
+ }
}
}
diff --git a/web-nodejs/public/js/chat.js b/web-nodejs/public/js/chat.js
index 36128fd9..db750736 100644
--- a/web-nodejs/public/js/chat.js
+++ b/web-nodejs/public/js/chat.js
@@ -940,7 +940,19 @@
}
function linkify(text) {
- return text.replace(/(https?:\/\/[^\s<]+)/g, '$1');
+ return text.replace(/(https?:\/\/[^\s<>"']+)/gi, (rawUrl) => {
+ try {
+ const parsed = new URL(rawUrl);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return Utils.escapeHtml(rawUrl);
+ }
+ const safeHref = Utils.escapeHtml(parsed.href);
+ const safeLabel = Utils.escapeHtml(rawUrl);
+ return `${safeLabel}`;
+ } catch (_) {
+ return Utils.escapeHtml(rawUrl);
+ }
+ });
}
function updateUnreadBadge(convId, count) {
diff --git a/web-nodejs/public/js/rdclient/client.js b/web-nodejs/public/js/rdclient/client.js
index 7cea522b..5425c14b 100644
--- a/web-nodejs/public/js/rdclient/client.js
+++ b/web-nodejs/public/js/rdclient/client.js
@@ -160,14 +160,20 @@ class RDClient {
let relayServer = rendezvousResponse.relayServer || '';
if (!relayUUID) {
- // Generate UUID for relay pairing (crypto.randomUUID requires
- // secure context HTTPS — use fallback for HTTP)
- relayUUID = (window.crypto && window.crypto.randomUUID
+ relayUUID = (window.crypto && typeof window.crypto.randomUUID === 'function'
? window.crypto.randomUUID()
- : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
- const r = Math.random() * 16 | 0;
- return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
- }));
+ : (() => {
+ const bytes = new Uint8Array(16);
+ if (window.crypto && window.crypto.getRandomValues) {
+ window.crypto.getRandomValues(bytes);
+ } else {
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+ })());
// Step 8: Send RequestRelay back to hbbs (signal server) via rendezvous
// so it can tell the target device to connect to relay with our UUID.
diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js
index d4bd3ff1..83eaaca2 100644
--- a/web-nodejs/public/js/settings.js
+++ b/web-nodejs/public/js/settings.js
@@ -848,6 +848,19 @@
if (panel) panel.classList.remove('hidden');
}
+ function isSafePreviewUrl(url) {
+ const trimmed = String(url || '').trim();
+ if (!trimmed) return false;
+ if (trimmed.startsWith('//') || trimmed.includes('..')) return false;
+ if (trimmed.startsWith('/')) return true;
+ try {
+ const parsed = new URL(trimmed, window.location.origin);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+ } catch (_) {
+ return false;
+ }
+ }
+
/**
* Sanitize SVG content to prevent XSS attacks.
* Removes potentially dangerous elements and attributes.
@@ -882,9 +895,20 @@
doc.querySelectorAll('*').forEach(el => {
dangerousAttrs.forEach(attr => el.removeAttribute(attr));
- // Remove href pointing to javascript:
- if (el.hasAttribute('href') && el.getAttribute('href').toLowerCase().trim().startsWith('javascript:')) {
- el.removeAttribute('href');
+ for (const attr of Array.from(el.attributes || [])) {
+ if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
+ }
+ if (el.hasAttribute('href')) {
+ const href = el.getAttribute('href').trim().toLowerCase();
+ if (/^(javascript|data|vbscript|file):/.test(href)) {
+ el.removeAttribute('href');
+ }
+ }
+ if (el.hasAttribute('xlink:href')) {
+ const href = el.getAttribute('xlink:href').trim().toLowerCase();
+ if (/^(javascript|data|vbscript|file):/.test(href)) {
+ el.removeAttribute('xlink:href');
+ }
}
});
@@ -921,7 +945,7 @@
}
} else if (type === 'image') {
const url = document.getElementById('logo-image-url')?.value || '';
- if (url.trim()) {
+ if (url.trim() && isSafePreviewUrl(url)) {
preview.innerHTML = `
`;
} else {
preview.innerHTML = `photo${Utils.escapeHtml(name)}`;
diff --git a/web-nodejs/routes/tickets.routes.js b/web-nodejs/routes/tickets.routes.js
index afc83bf2..dd4b68a4 100644
--- a/web-nodejs/routes/tickets.routes.js
+++ b/web-nodejs/routes/tickets.routes.js
@@ -35,7 +35,7 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { getAdapter } = require('../services/dbAdapter');
-const { uploadLimiter } = require('../middleware/rateLimiter');
+const { uploadLimiter, fileAccessLimiter } = require('../middleware/rateLimiter');
// ---------------------------------------------------------------------------
// Config
@@ -416,7 +416,7 @@ router.post('/:id(\\d+)/attachments', uploadLimiter, requireAdminOrOperator, asy
/**
* GET /api/tickets/:id/attachments — List attachments.
*/
-router.get('/:id(\\d+)/attachments', requireAuth, async (req, res) => {
+router.get('/:id(\\d+)/attachments', fileAccessLimiter, requireAuth, async (req, res) => {
try {
const adapter = getAdapter();
const attachments = await adapter.getTicketAttachments(+req.params.id);
@@ -439,7 +439,7 @@ router.get('/:id(\\d+)/attachments', requireAuth, async (req, res) => {
/**
* GET /api/tickets/attachments/:aid — Download attachment file.
*/
-router.get('/attachments/:aid(\\d+)', requireAuth, async (req, res) => {
+router.get('/attachments/:aid(\\d+)', fileAccessLimiter, requireAuth, async (req, res) => {
try {
const adapter = getAdapter();
const att = await adapter.getAttachmentById(+req.params.aid);
diff --git a/web-nodejs/services/fontService.js b/web-nodejs/services/fontService.js
index 016b7a57..99cc1ba1 100644
--- a/web-nodejs/services/fontService.js
+++ b/web-nodejs/services/fontService.js
@@ -260,8 +260,8 @@ async function downloadFont(family, weights = ['400', '500', '600', '700']) {
if (downloadedFiles.length > 0) {
const cssFaces = downloadedFiles.map(file => {
const weightMatch = file.match(/-(\d+)\.woff2$/);
- const weight = (weightMatch && weightMatch[1]) || '400';
- const cssFamily = String(family).replace(/['\\]/g, '');
+ const weight = (weightMatch && /^\d+$/.test(weightMatch[1])) ? weightMatch[1] : '400';
+ const cssFamily = String(family).replace(/[^a-zA-Z0-9\s-]/g, '').trim() || 'sans-serif';
return [
'@font-face {',
` font-family: '${cssFamily}';`,
diff --git a/web-nodejs/services/networkMonitor.js b/web-nodejs/services/networkMonitor.js
index 976f5128..dace1636 100644
--- a/web-nodejs/services/networkMonitor.js
+++ b/web-nodejs/services/networkMonitor.js
@@ -34,6 +34,7 @@ const MONITOR_OPTS = { allowPrivate: true };
const DEFAULT_POLL_INTERVAL_MS = 60_000; // 1 minute
const DEFAULT_TIMEOUT_MS = 5_000; // 5 seconds
const MAX_HISTORY_ROWS = 10_000; // per target
+const MAX_TARGETS_PER_POLL = 500; // cap poll workload
const CLEANUP_INTERVAL_MS = 3600_000; // 1 hour
// Security: Strict hostname/IP validation regex
@@ -237,6 +238,7 @@ class NetworkMonitor {
this.db = dbAdapter;
this.running = false;
this.timer = null;
+ this._pollInFlight = false;
this.pollInterval = DEFAULT_POLL_INTERVAL_MS;
}
@@ -328,7 +330,8 @@ class NetworkMonitor {
if (!targets || targets.length === 0) return [];
const results = [];
- for (const target of targets) {
+ const batch = targets.slice(0, MAX_TARGETS_PER_POLL);
+ for (const target of batch) {
const result = await this.checkTarget(target);
results.push(result);
}
@@ -342,16 +345,18 @@ class NetworkMonitor {
// --- Internal ---
async _poll() {
- if (!this.running) return;
+ if (!this.running || this._pollInFlight) return;
+ this._pollInFlight = true;
try {
await this.checkAll();
} catch (err) {
console.error('[NetworkMonitor] Poll error:', err.message);
- }
-
- if (this.running) {
- this.timer = setTimeout(() => this._poll(), this.pollInterval);
+ } finally {
+ this._pollInFlight = false;
+ if (this.running) {
+ this.timer = setTimeout(() => this._poll(), this.pollInterval);
+ }
}
}
}
diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js
index 2d6d9b7b..68023be0 100644
--- a/web-nodejs/services/updateService.js
+++ b/web-nodejs/services/updateService.js
@@ -535,6 +535,7 @@ let _updateInProgress = false;
/**
* Run a shell command as a promise (non-blocking unlike execSync).
+ * Prefer spawnPromise() when arguments are known — avoids shell interpolation.
*/
function execPromise(cmd, opts = {}) {
return new Promise((resolve, reject) => {
@@ -550,6 +551,27 @@ function execPromise(cmd, opts = {}) {
});
}
+function spawnPromise(command, args, opts = {}) {
+ return new Promise((resolve, reject) => {
+ const { spawn } = require('child_process');
+ const proc = spawn(command, args, { shell: false, ...opts });
+ let stdout = '';
+ let stderr = '';
+ proc.stdout?.on('data', (chunk) => { stdout += chunk; });
+ proc.stderr?.on('data', (chunk) => { stderr += chunk; });
+ proc.on('error', reject);
+ proc.on('close', (code) => {
+ if (code !== 0) {
+ const err = new Error(`${command} exited with code ${code}`);
+ err.stderr = stderr;
+ err.stdout = stdout;
+ return reject(err);
+ }
+ resolve({ stdout, stderr });
+ });
+ });
+}
+
/**
* Copy directory recursively.
*/
@@ -1125,18 +1147,17 @@ async function buildGoServer(preferredGoBinPath = null) {
const start = Date.now();
const goBin = goCheck.binPath || 'go';
const buildEnv = buildEnvWithGo(goBin);
- // Quote when path contains spaces (Windows "Program Files")
- const goCmd = /\s/.test(goBin) ? `"${goBin}"` : goBin;
try {
- await execPromise(`${goCmd} mod download`, {
+ await spawnPromise(goBin, ['mod', 'download'], {
cwd: serverDir,
timeout: 120000,
env: buildEnv
});
- await execPromise(
- `${goCmd} build -trimpath -ldflags="-s -w" -o "${binaryName}" .`,
+ await spawnPromise(
+ goBin,
+ ['build', '-trimpath', '-ldflags=-s -w', '-o', binaryName, '.'],
{ cwd: serverDir, timeout: 600000, env: buildEnv }
);
@@ -1380,7 +1401,7 @@ async function _installGoToolchainBody(onProgress, opts = {}) {
{ timeout: 300000 }
);
} else {
- await execPromise(`tar -xzf "${archivePath}" -C "${GO_TOOLCHAIN_DIR}"`, { timeout: 300000 });
+ await spawnPromise('tar', ['-xzf', archivePath, '-C', GO_TOOLCHAIN_DIR], { timeout: 300000 });
}
try { fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ }
@@ -2578,6 +2599,14 @@ function isValidBackupName(name) {
return typeof name === 'string' && /^pre-update-[\d\-T]+$/.test(name);
}
+function isValidManifestRelativePath(relPath) {
+ if (typeof relPath !== 'string' || relPath.length === 0 || relPath.includes('\0')) return false;
+ const normalized = relPath.replace(/\\/g, '/');
+ if (path.isAbsolute(normalized) || normalized.startsWith('/') || normalized.startsWith('..')) return false;
+ if (normalized.split('/').some((seg) => seg === '..')) return false;
+ return true;
+}
+
/**
* Recursively delete a directory. Refuses to delete anything outside
* BACKUP_DIR to defend against path-traversal bugs upstream.
@@ -2635,6 +2664,9 @@ function restoreFromBackup(backupName) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
let restored = 0;
for (const filePath of (manifest.files || [])) {
+ if (!isValidManifestRelativePath(filePath)) {
+ throw new Error(`Invalid path in backup manifest: ${filePath}`);
+ }
const src = resolvePathUnderRoot(backupPath, filePath);
const dest = resolvePathUnderRoot(ROOT_DIR, filePath);
if (fs.existsSync(src)) {