Files
BetterDesk/web-nodejs/services/userScopeService.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

166 lines
5.7 KiB
JavaScript

/**
* User device scope — folder ACL sync, peer grants, effective visibility.
*/
const config = require('../config/config');
const deviceGroupService = require('./deviceGroupService');
function folderGroupGuid(folderId) {
return `folder_${folderId}`;
}
function normalizeFolderIds(value) {
const raw = Array.isArray(value) ? value : [];
return Array.from(new Set(raw.map(v => Number.parseInt(v, 10)).filter(Number.isFinite))).slice(0, 200);
}
function normalizePeerIds(value) {
const raw = Array.isArray(value) ? value : String(value || '').split(',');
return Array.from(new Set(raw.map(v => String(v || '').trim()).filter(Boolean))).slice(0, 500);
}
async function ensureFolderMirrorGroup(db, folder) {
const guid = folderGroupGuid(folder.id);
const payload = {
guid,
name: folder.name,
note: 'BetterDesk folder access scope',
source_type: 'manual',
tag_filter: ''
};
let group = await db.getDeviceGroupByGuid(guid);
if (group) {
await db.updateDeviceGroup(guid, payload);
group = await db.getDeviceGroupByGuid(guid);
} else if (typeof db.createDeviceGroup === 'function') {
group = await db.createDeviceGroup(payload);
}
return group;
}
async function getUserFolderIds(db, username) {
if (!username || typeof db.getAllFolders !== 'function') return [];
const folders = await db.getAllFolders();
const result = [];
for (const folder of folders || []) {
const group = await db.getDeviceGroupByGuid(folderGroupGuid(folder.id));
if (!group) continue;
const users = deviceGroupService.normalizeUsernames(group.allowed_users);
if (users.includes(username)) result.push(Number(folder.id));
}
return result;
}
async function syncUserFolderAccess(db, username, folderIds) {
if (!username || typeof db.getAllFolders !== 'function') return [];
const selected = new Set(normalizeFolderIds(folderIds));
const folders = await db.getAllFolders();
for (const folder of folders || []) {
const group = await ensureFolderMirrorGroup(db, folder);
if (!group) continue;
const currentUsers = deviceGroupService.normalizeUsernames(group.allowed_users);
const wantAccess = selected.has(Number(folder.id));
const hasAccess = currentUsers.includes(username);
if (wantAccess && !hasAccess) {
await db.setDeviceGroupUserAccess(group.guid, [...currentUsers, username]);
} else if (!wantAccess && hasAccess) {
await db.setDeviceGroupUserAccess(
group.guid,
currentUsers.filter(name => name !== username)
);
}
}
return Array.from(selected);
}
function peerGrantsUnavailableError(action) {
const error = new Error(
`Per-user device grants are unavailable (database.${action} missing). Refusing to silently no-op.`
);
error.status = 500;
error.code = 'PEER_GRANTS_UNAVAILABLE';
return error;
}
/**
* Best-effort unknown-device warnings only — never blocks grant persistence.
* Caps log noise so a large peerIds payload cannot flood the console.
*/
async function warnUnknownPeerIds(db, peerIds) {
const ids = Array.isArray(peerIds) ? peerIds : [];
if (!ids.length) return;
const lookup = typeof db.getPeerById === 'function'
? (id) => db.getPeerById(id)
: typeof db.getDeviceById === 'function'
? (id) => db.getDeviceById(id)
: null;
if (!lookup) return;
const maxWarnings = 20;
let warned = 0;
for (const peerId of ids) {
if (warned >= maxWarnings) {
console.warn(
`[userScope] peer grant lookup capped after ${maxWarnings} warnings ` +
`(${ids.length - maxWarnings} remaining unchecked)`
);
break;
}
try {
const row = await lookup(peerId);
if (!row) {
console.warn('[userScope] peer grant references unknown device id:', peerId);
warned += 1;
}
} catch (err) {
console.warn('[userScope] peer id lookup failed for', peerId, err.message);
warned += 1;
}
}
}
async function getUserPeerGrantIds(db, userId) {
if (!userId) return [];
// Read path stays soft: Users list / enrichment must not 500 if a method is missing.
if (typeof db.getUserPeerGrants !== 'function') {
console.error('[userScope] getUserPeerGrants missing on database facade — returning []');
return [];
}
return db.getUserPeerGrants(userId);
}
async function syncUserPeerGrants(db, userId, peerIds) {
if (!userId) return [];
// Write path fails closed: never report success while dropping peerIds (#380).
if (typeof db.setUserPeerGrants !== 'function') {
throw peerGrantsUnavailableError('setUserPeerGrants');
}
const normalized = normalizePeerIds(peerIds);
await db.setUserPeerGrants(userId, normalized);
await warnUnknownPeerIds(db, normalized);
return normalized;
}
async function countEffectiveScope(db, user, devices) {
const scope = await deviceGroupService.getDeviceScopeForUser(db, user, devices);
if (scope === null) return { count: (devices || []).length, restricted: false };
return { count: scope.size, restricted: true };
}
function isDeviceScopeRestrictedDefault() {
return String(config.deviceScopeDefault || 'open').toLowerCase() === 'restricted';
}
module.exports = {
folderGroupGuid,
normalizeFolderIds,
normalizePeerIds,
getUserFolderIds,
syncUserFolderAccess,
getUserPeerGrantIds,
syncUserPeerGrants,
warnUnknownPeerIds,
countEffectiveScope,
isDeviceScopeRestrictedDefault
};