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 = `${Utils.escapeHtml(name)}`; } 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)) {