Files
BetterDesk/web-nodejs/tests/updateService.consoleSync.test.js
T
UNITRONIX d7a2fe6562 fix(security): harden update restore and notification CSRF
Protect backup restores and session-authenticated notification writes from unsafe state changes, while clearing CodeQL false positives without weakening intentional TLS pinning.

Refs CodeQL alerts #297-310

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-08-23 21:08:05 +02:00

164 lines
7.1 KiB
JavaScript

'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const config = require('../config/config');
const { createConsoleDeployGraph } = require('../lib/consoleDeployGraph');
const {
GITHUB_COMPARE_FILE_LIMIT,
isCompareLikelyTruncated,
isRetryableDownloadStatus,
getDownloadRetryDelayMs,
ensureGoServerSignalRelayPorts,
restoreServerBinaryBackup,
restoreFromBackup,
} = require('../services/updateService');
describe('updateService console sync helpers', () => {
test('detects truncated GitHub compare responses', () => {
expect(GITHUB_COMPARE_FILE_LIMIT).toBe(300);
expect(isCompareLikelyTruncated(299)).toBe(false);
expect(isCompareLikelyTruncated(300)).toBe(true);
expect(isCompareLikelyTruncated(450)).toBe(true);
});
test('resolves relative console require paths', () => {
const graph = createConsoleDeployGraph(require('path').join(__dirname, '..'));
expect(graph.resolveConsoleRequire('routes/auth.routes.js', '../services/serverAttestation'))
.toBe('services/serverAttestation.js');
expect(graph.resolveConsoleRequire('routes/index.js', './devices.routes'))
.toBe('routes/devices.routes.js');
expect(graph.resolveConsoleRequire('server.js', './routes'))
.toBe('routes/index.js');
expect(graph.resolveConsoleRequire('server.js', 'express')).toBeNull();
});
test('skips phantom routes.js when routes/index.js exists during repair scan', () => {
const graph = createConsoleDeployGraph(require('path').join(__dirname, '..'));
expect(graph.isResolvedByIndexModule('routes.js')).toBe(true);
expect(graph.isResolvedByIndexModule('routes/auth.routes.js')).toBe(false);
});
test('ignores require examples inside comments when scanning dependencies', () => {
const graph = createConsoleDeployGraph(require('path').join(__dirname, '..'));
const required = graph.collectConsoleRequiredFiles([
{ localPath: 'scripts/linux-ensure-console-user.js' },
]);
expect(required.has('scripts/linux-ensure-console-user.js')).toBe(true);
expect(required.has('scripts/scripts/linux-ensure-console-user.js')).toBe(false);
expect(required.has('routes.js')).toBe(false);
expect(required.has('routes/index.js')).toBe(true);
});
test('collects serverAttestation from auth.routes integrity seeds', () => {
const graph = createConsoleDeployGraph(require('path').join(__dirname, '..'));
const required = graph.collectConsoleRequiredFiles([
{ localPath: 'routes/auth.routes.js' },
]);
expect(required.has('routes/auth.routes.js')).toBe(true);
expect(required.has('services/serverAttestation.js')).toBe(true);
});
test('retries GitHub raw downloads on rate limit status codes', () => {
expect(isRetryableDownloadStatus(429)).toBe(true);
expect(isRetryableDownloadStatus(503)).toBe(true);
expect(isRetryableDownloadStatus(404)).toBe(false);
expect(getDownloadRetryDelayMs(1)).toBe(1000);
expect(getDownloadRetryDelayMs(2)).toBe(2000);
expect(getDownloadRetryDelayMs(6)).toBe(15000);
});
test('ensureGoServerSignalRelayPorts adds SIGNAL_PORT, RELAY_PORT, and GO_API_PORT (#219)', () => {
const unit = [
'[Service]',
'User=root',
'Environment=AUTH_DB_PATH=/opt/console/data/auth.db',
'ExecStart=/opt/betterdesk/betterdesk-server -mode all',
].join('\n');
const patched = ensureGoServerSignalRelayPorts(unit);
expect(patched.changed).toBe(true);
expect(patched.text).toMatch(/^Environment=SIGNAL_PORT=21116$/m);
expect(patched.text).toMatch(/^Environment=RELAY_PORT=21117$/m);
expect(patched.text).toMatch(/^Environment=GO_API_PORT=21114$/m);
const again = ensureGoServerSignalRelayPorts(patched.text);
expect(again.changed).toBe(false);
});
test('restores a validated server binary backup atomically', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-binary-rollback-'));
const target = path.join(root, 'betterdesk-server.exe');
const backup = `${target}.bak.test`;
try {
fs.writeFileSync(target, 'new');
fs.writeFileSync(backup, 'old');
expect(restoreServerBinaryBackup(backup, target)).toMatchObject({ restored: true });
expect(fs.readFileSync(target, 'utf8')).toBe('old');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects a binary backup outside the target directory', () => {
expect(restoreServerBinaryBackup(
path.join(os.tmpdir(), 'betterdesk-server.exe.bak.test'),
path.join(os.tmpdir(), 'other', 'betterdesk-server.exe')
)).toMatchObject({
restored: false,
error: 'Server binary backup path failed validation',
});
});
test('rejects traversal paths from a backup manifest', () => {
const backupRoot = path.join(config.dataDir, 'backups');
const backupName = `pre-update-${Date.now()}-${process.pid}`;
const backupPath = path.join(backupRoot, backupName);
fs.mkdirSync(backupPath, { recursive: true });
fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({
files: ['../outside.txt'],
}));
try {
expect(() => restoreFromBackup(backupName))
.toThrow('Invalid path in backup manifest');
} finally {
fs.rmSync(backupPath, { recursive: true, force: true });
}
});
test('refuses to restore protected runtime files from a backup manifest', () => {
const backupRoot = path.join(config.dataDir, 'backups');
const backupName = `pre-update-${Date.now()}-${process.pid}`;
const backupPath = path.join(backupRoot, backupName);
fs.mkdirSync(backupPath, { recursive: true });
fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({
files: ['console/.env'],
}));
try {
expect(() => restoreFromBackup(backupName))
.toThrow('Refusing to restore protected runtime path');
} finally {
fs.rmSync(backupPath, { recursive: true, force: true });
}
});
test('refuses to remove protected runtime files from a backup manifest', () => {
const backupRoot = path.join(config.dataDir, 'backups');
const backupName = `pre-update-${Date.now()}-${process.pid}`;
const backupPath = path.join(backupRoot, backupName);
fs.mkdirSync(backupPath, { recursive: true });
fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({
removeOnRestore: ['console/.env'],
}));
try {
expect(() => restoreFromBackup(backupName))
.toThrow('Refusing to remove protected runtime path');
} finally {
fs.rmSync(backupPath, { recursive: true, force: true });
}
});
});