chore: update Docker Compose image tags to 3.2.12 and enhance documentation for versioning

This commit is contained in:
UNITRONIX
2026-06-11 18:36:48 +02:00
parent a2621b936a
commit 577fc79a97
12 changed files with 145 additions and 36 deletions
+12
View File
@@ -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
+6 -3
View File
@@ -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).
+1 -1
View File
@@ -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.
+10
View File
@@ -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',
+8 -3
View File
@@ -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];
}
}
}
+13 -1
View File
@@ -940,7 +940,19 @@
}
function linkify(text) {
return text.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
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 `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
} catch (_) {
return Utils.escapeHtml(rawUrl);
}
});
}
function updateUnreadBadge(convId, count) {
+13 -7
View File
@@ -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.
+28 -4
View File
@@ -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 = `<img src="${Utils.escapeHtml(url)}" alt="${Utils.escapeHtml(name)}" style="max-height: 36px;">`;
} else {
preview.innerHTML = `<span class="material-icons">photo</span><span class="logo-preview-text">${Utils.escapeHtml(name)}</span>`;
+3 -3
View File
@@ -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);
+2 -2
View File
@@ -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}';`,
+11 -6
View File
@@ -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);
}
}
}
}
+38 -6
View File
@@ -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)) {