mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
chore: update Docker Compose image tags to 3.2.12 and enhance documentation for versioning
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}';`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user