mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
fix: enhance API handling and rate limiting for panel and desktop layout
- Resolved issues with panel tabs redirecting to the dashboard on 401 errors by allowing browser requests without Bearer tokens to fall through to panel handlers. - Extended the rate limit whitelist for Devices/Users API endpoints to prevent 429 errors during high load. - Introduced a new rate limiter for desktop layout preference saves, ensuring session-authenticated writes are managed effectively. - Implemented staggered loading for API requests on the Devices page to optimize performance and reduce rate limit bursts. - Updated relevant tests to cover new fallthrough behavior and rate limiting logic.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Panel tabs redirect to dashboard (401):** RustDesk client API routes (`GET /api/devices`, `/api/strategies`) no longer shadow panel session routes — browser requests without Bearer token fall through to panel handlers; `users.js` uses `/api/panel/strategies`; `Utils.api` no longer redirects logged-in users to `/login` (which bounced to dashboard) on incidental 401.
|
||||
- **Devices/Users 429 rate limit:** extended panel poll whitelist (`/api/folders`, `/api/tags`, `/api/device-groups`, `/api/bd/notifications`, `/api/panel/*`); dedicated limiter for `POST /api/desktop/layout`; staggered Devices page API loads; desktop widget layout saves gated when desktop mode is inactive.
|
||||
|
||||
### Changed
|
||||
- _(none yet)_
|
||||
|
||||
|
||||
@@ -31,12 +31,17 @@ const PANEL_POLL_PATHS = new Set([
|
||||
'/api/system/info',
|
||||
'/api/logs/recent',
|
||||
'/api/database/stats',
|
||||
'/api/docker/containers'
|
||||
'/api/docker/containers',
|
||||
'/api/folders',
|
||||
'/api/tags',
|
||||
'/api/device-groups',
|
||||
'/api/bd/notifications'
|
||||
]);
|
||||
|
||||
/** Prefixes for read-only dashboard sub-routes (future-safe). */
|
||||
const PANEL_POLL_PREFIXES = [
|
||||
'/api/dashboard/'
|
||||
'/api/dashboard/',
|
||||
'/api/panel/'
|
||||
];
|
||||
|
||||
function isPanelPollRequest(req) {
|
||||
@@ -47,6 +52,13 @@ function isPanelPollRequest(req) {
|
||||
return PANEL_POLL_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
/** Session-authenticated UI preference writes (desktop layout save). */
|
||||
function isPanelPreferenceWrite(req) {
|
||||
const method = String(req.method || 'GET').toUpperCase();
|
||||
if (method !== 'POST') return false;
|
||||
return (req.path || '') === '/api/desktop/layout';
|
||||
}
|
||||
|
||||
/** Paths that receive widgetLimiter in server.js (exact paths only). */
|
||||
function getPanelPollMountPaths() {
|
||||
return Array.from(PANEL_POLL_PATHS);
|
||||
@@ -70,7 +82,7 @@ const apiLimiter = rateLimit({
|
||||
error: 'Too many requests. Please try again later.'
|
||||
},
|
||||
keyGenerator: defaultKeyGenerator,
|
||||
skip: (req) => isPanelPollRequest(req)
|
||||
skip: (req) => isPanelPollRequest(req) || isPanelPreferenceWrite(req)
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -90,6 +102,25 @@ const widgetLimiter = rateLimit({
|
||||
keyGenerator: defaultKeyGenerator
|
||||
});
|
||||
|
||||
/**
|
||||
* Desktop layout / wallpaper preference saves (session + CSRF, debounced in UI).
|
||||
*/
|
||||
const panelPreferenceLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: parseInt(process.env.PANEL_PREFERENCE_RATE_LIMIT_MAX, 10) || 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
success: false,
|
||||
error: 'Too many layout save requests. Please slow down.'
|
||||
},
|
||||
keyGenerator: (req) => {
|
||||
const userId = req.session && req.session.userId;
|
||||
if (userId) return `pref:${userId}`;
|
||||
return defaultKeyGenerator(req);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* RdClient HTML page limiter. Keeps remote viewer/login pages bounded without
|
||||
* using the stricter credential-attempt budget.
|
||||
@@ -170,12 +201,14 @@ const fileAccessLimiter = rateLimit({
|
||||
module.exports = {
|
||||
apiLimiter,
|
||||
widgetLimiter,
|
||||
panelPreferenceLimiter,
|
||||
rdClientPageLimiter,
|
||||
loginLimiter,
|
||||
passwordChangeLimiter,
|
||||
uploadLimiter,
|
||||
fileAccessLimiter,
|
||||
isPanelPollRequest,
|
||||
isPanelPreferenceWrite,
|
||||
getPanelPollMountPaths,
|
||||
PANEL_POLL_PATHS
|
||||
};
|
||||
|
||||
@@ -382,8 +382,13 @@
|
||||
} catch (_) { /* ignore corrupt data */ }
|
||||
}
|
||||
|
||||
function layoutPersistenceEnabled() {
|
||||
return document.body.classList.contains('desktop-active') || _widgets.size > 0;
|
||||
}
|
||||
|
||||
var _saveTimeout = null;
|
||||
function saveLayout() {
|
||||
if (!layoutPersistenceEnabled()) return;
|
||||
clearTimeout(_saveTimeout);
|
||||
_saveTimeout = setTimeout(function () {
|
||||
var arr = [];
|
||||
@@ -395,6 +400,7 @@
|
||||
}
|
||||
|
||||
function saveLayoutToServer(arr) {
|
||||
if (!layoutPersistenceEnabled()) return;
|
||||
if (!window.BetterDesk || !window.BetterDesk.csrfToken) return;
|
||||
if (typeof Utils === 'undefined' || !Utils.api) return;
|
||||
Utils.api('/api/desktop/layout', {
|
||||
@@ -2041,6 +2047,7 @@
|
||||
var _prevCanvasArea = null;
|
||||
|
||||
function autoReposition() {
|
||||
if (!layoutPersistenceEnabled()) return;
|
||||
var area = getCanvasArea();
|
||||
if (area.w < 200 || area.h < 200) return;
|
||||
|
||||
@@ -2091,6 +2098,7 @@
|
||||
// Watch for window resize and auto-reposition
|
||||
var _repositionTimeout = null;
|
||||
window.addEventListener('resize', function () {
|
||||
if (!layoutPersistenceEnabled()) return;
|
||||
clearTimeout(_repositionTimeout);
|
||||
_repositionTimeout = setTimeout(autoReposition, 300);
|
||||
});
|
||||
|
||||
@@ -75,6 +75,19 @@
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50, 100];
|
||||
let contextMenuState = null;
|
||||
let hScrollSyncing = false;
|
||||
const pendingRequests = new Map();
|
||||
const LOAD_STAGGER_MS = 120;
|
||||
|
||||
function fetchOnce(endpoint, fetcher) {
|
||||
if (pendingRequests.has(endpoint)) return pendingRequests.get(endpoint);
|
||||
const request = fetcher().finally(() => pendingRequests.delete(endpoint));
|
||||
pendingRequests.set(endpoint, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function scheduleLoad(fn, index) {
|
||||
setTimeout(fn, index * LOAD_STAGGER_MS);
|
||||
}
|
||||
|
||||
// Elements
|
||||
let tableBody, pagination, emptyState, bulkActions, selectedCountEl;
|
||||
@@ -88,13 +101,13 @@
|
||||
bulkActions = document.getElementById('bulk-actions');
|
||||
selectedCountEl = document.getElementById('selected-count');
|
||||
|
||||
// Load data
|
||||
loadFolders();
|
||||
loadUserGroups();
|
||||
loadDeviceGroups();
|
||||
loadStrategies();
|
||||
loadTags();
|
||||
loadDevices();
|
||||
// Load data (staggered to avoid rate-limit bursts on page entry)
|
||||
scheduleLoad(loadFolders, 0);
|
||||
scheduleLoad(loadUserGroups, 1);
|
||||
scheduleLoad(loadDeviceGroups, 2);
|
||||
scheduleLoad(loadStrategies, 3);
|
||||
scheduleLoad(loadTags, 4);
|
||||
scheduleLoad(loadDevices, 5);
|
||||
|
||||
// Event listeners
|
||||
initSearch();
|
||||
@@ -643,7 +656,8 @@
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const qs = showDeleted ? '?includeDeleted=true' : '';
|
||||
const response = await Utils.api('/api/devices' + qs);
|
||||
const endpoint = '/api/devices' + qs;
|
||||
const response = await fetchOnce(endpoint, () => Utils.api(endpoint));
|
||||
devices = response.devices || [];
|
||||
|
||||
// Update count
|
||||
@@ -691,7 +705,7 @@
|
||||
|
||||
async function loadUserGroups() {
|
||||
try {
|
||||
const response = await Utils.api('/api/panel/user-groups');
|
||||
const response = await fetchOnce('/api/panel/user-groups', () => Utils.api('/api/panel/user-groups'));
|
||||
availableUserGroups = response.groups || [];
|
||||
userGroupsLoaded = true;
|
||||
} catch (error) {
|
||||
@@ -2183,7 +2197,7 @@
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const response = await Utils.api('/api/tags');
|
||||
const response = await fetchOnce('/api/tags', () => Utils.api('/api/tags'));
|
||||
availableTags = response.tags || [];
|
||||
renderTagFilters();
|
||||
} catch (error) {
|
||||
@@ -2238,7 +2252,7 @@
|
||||
|
||||
async function loadDeviceGroups() {
|
||||
try {
|
||||
const response = await Utils.api('/api/device-groups');
|
||||
const response = await fetchOnce('/api/device-groups', () => Utils.api('/api/device-groups'));
|
||||
deviceGroups = response.groups || [];
|
||||
window._betterdesk_device_groups = deviceGroups;
|
||||
renderDeviceGroups();
|
||||
@@ -2360,7 +2374,7 @@
|
||||
|
||||
async function loadStrategies() {
|
||||
try {
|
||||
const response = await Utils.api('/api/panel/strategies');
|
||||
const response = await fetchOnce('/api/panel/strategies', () => Utils.api('/api/panel/strategies'));
|
||||
accessStrategies = response.strategies || [];
|
||||
strategiesLoaded = true;
|
||||
renderStrategies();
|
||||
@@ -2679,7 +2693,7 @@
|
||||
*/
|
||||
async function loadFolders() {
|
||||
try {
|
||||
const response = await Utils.api('/api/folders');
|
||||
const response = await fetchOnce('/api/folders', () => Utils.api('/api/folders'));
|
||||
folders = response.folders || [];
|
||||
// Expose folders globally for DeviceDetail panel
|
||||
window._betterdesk_folders = folders;
|
||||
|
||||
@@ -139,8 +139,8 @@
|
||||
|
||||
async function loadStrategies() {
|
||||
try {
|
||||
const response = await Utils.api('/api/strategies');
|
||||
strategies = Array.isArray(response) ? response : (response.data || []);
|
||||
const response = await Utils.api('/api/panel/strategies');
|
||||
strategies = response.strategies || [];
|
||||
strategiesLoaded = true;
|
||||
} catch (error) {
|
||||
strategies = [];
|
||||
|
||||
@@ -253,11 +253,17 @@ const Utils = {
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
var path = window.location.pathname || '';
|
||||
var panelUser = window.BetterDesk && window.BetterDesk.user;
|
||||
if (path.startsWith('/remote') && !path.startsWith('/remote/login')) {
|
||||
window.location.href = '/remote/login?return=' +
|
||||
encodeURIComponent(path + (window.location.search || '')) + '&expired=1';
|
||||
} else {
|
||||
} else if (!panelUser) {
|
||||
window.location.href = '/login';
|
||||
} else if (typeof Notifications !== 'undefined' && Notifications.error) {
|
||||
var msg = (typeof _ === 'function' ? _('auth.totp_session_expired') : null)
|
||||
|| error.message
|
||||
|| 'Unauthorized';
|
||||
Notifications.error(msg);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -128,6 +128,17 @@ async function authenticateRequest(req) {
|
||||
return authService.validateAccessToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel browser requests use session cookies, not Bearer tokens.
|
||||
* Fall through to panel routes mounted later in server.js (same as GET /api/users).
|
||||
*/
|
||||
function fallthroughUnlessBearer(req, res, next) {
|
||||
if (!extractBearerToken(req)) {
|
||||
return next('route');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: require Bearer auth
|
||||
*/
|
||||
@@ -1888,7 +1899,7 @@ router.post('/api/user-groups', requireAuth, requireAdmin, async (req, res) => {
|
||||
* GET /api/strategies
|
||||
* List all access control strategies.
|
||||
*/
|
||||
router.get('/api/strategies', requireAuth, async (req, res) => {
|
||||
router.get('/api/strategies', fallthroughUnlessBearer, requireAuth, async (req, res) => {
|
||||
try {
|
||||
const strategies = await db.getAllStrategies();
|
||||
return res.json({
|
||||
@@ -1988,7 +1999,7 @@ async function resolveRustDeskStrategyRefs(body = {}) {
|
||||
/**
|
||||
* GET /api/strategies/:guid
|
||||
*/
|
||||
router.get('/api/strategies/:guid', requireAuth, async (req, res) => {
|
||||
router.get('/api/strategies/:guid', fallthroughUnlessBearer, requireAuth, async (req, res) => {
|
||||
try {
|
||||
const guid = sanitizeStr(req.params.guid || '', 64);
|
||||
if (!guid) return res.status(400).json({ error: 'Strategy guid is required' });
|
||||
@@ -2049,7 +2060,7 @@ router.put('/api/strategies/:guid/status', requireAuth, requireAdmin, async (req
|
||||
/**
|
||||
* GET /api/devices — Pro admin list (id + guid)
|
||||
*/
|
||||
router.get('/api/devices', requireAuth, async (req, res) => {
|
||||
router.get('/api/devices', fallthroughUnlessBearer, requireAuth, async (req, res) => {
|
||||
try {
|
||||
const idFilter = sanitizeStr(req.query.id || '', 64);
|
||||
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize, 10) || 50, 1), 1000);
|
||||
|
||||
@@ -18,7 +18,7 @@ const https = require('https');
|
||||
const config = require('./config/config');
|
||||
const securityMiddleware = require('./middleware/security');
|
||||
const { initI18n } = require('./middleware/i18n');
|
||||
const { apiLimiter, widgetLimiter, getPanelPollMountPaths } = require('./middleware/rateLimiter');
|
||||
const { apiLimiter, widgetLimiter, panelPreferenceLimiter, getPanelPollMountPaths } = require('./middleware/rateLimiter');
|
||||
const { csrfTokenProvider, doubleCsrfProtection, downgradeToHttp: csrfDowngradeToHttp } = require('./middleware/csrf');
|
||||
const { roleHasPermission, isSuperAdminRole } = require('./middleware/auth');
|
||||
const authService = require('./services/authService');
|
||||
@@ -157,6 +157,8 @@ app.use('/wallpapers', express.static(path.join(__dirname, 'wallpapers'), {
|
||||
for (const p of getPanelPollMountPaths()) {
|
||||
app.use(p, widgetLimiter);
|
||||
}
|
||||
app.use('/api/panel', widgetLimiter);
|
||||
app.use('/api/desktop/layout', panelPreferenceLimiter);
|
||||
app.use('/api/', apiLimiter);
|
||||
|
||||
// RustDesk Client API — mounted BEFORE CSRF because desktop clients use Bearer
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Panel poll rate-limit classification tests
|
||||
*/
|
||||
|
||||
const { isPanelPollRequest, PANEL_POLL_PATHS } = require('../middleware/rateLimiter');
|
||||
const { isPanelPollRequest, isPanelPreferenceWrite, PANEL_POLL_PATHS } = require('../middleware/rateLimiter');
|
||||
|
||||
describe('rateLimiter panel poll paths', () => {
|
||||
it('classifies dashboard client-config GET as panel poll', () => {
|
||||
@@ -19,7 +19,25 @@ describe('rateLimiter panel poll paths', () => {
|
||||
expect(PANEL_POLL_PATHS.has('/api/dashboard/activity')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes devices page read paths in the poll set', () => {
|
||||
expect(PANEL_POLL_PATHS.has('/api/folders')).toBe(true);
|
||||
expect(PANEL_POLL_PATHS.has('/api/tags')).toBe(true);
|
||||
expect(PANEL_POLL_PATHS.has('/api/device-groups')).toBe(true);
|
||||
expect(PANEL_POLL_PATHS.has('/api/bd/notifications')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies /api/panel/* GET as panel poll via prefix', () => {
|
||||
expect(isPanelPollRequest({ method: 'GET', path: '/api/panel/strategies' })).toBe(true);
|
||||
expect(isPanelPollRequest({ method: 'GET', path: '/api/panel/user-groups' })).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify unrelated API paths as panel poll', () => {
|
||||
expect(isPanelPollRequest({ method: 'GET', path: '/api/settings/info' })).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies desktop layout POST as panel preference write', () => {
|
||||
expect(isPanelPreferenceWrite({ method: 'POST', path: '/api/desktop/layout' })).toBe(true);
|
||||
expect(isPanelPreferenceWrite({ method: 'GET', path: '/api/desktop/layout' })).toBe(false);
|
||||
expect(isPanelPreferenceWrite({ method: 'POST', path: '/api/devices' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,4 +457,60 @@ describe('RustDesk Client API routes', () => {
|
||||
expect(db.saveAddressBook).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel route fallthrough (session cookie, no Bearer)', () => {
|
||||
it('GET /api/devices falls through to panel routes', async () => {
|
||||
const panelApp = createTestApp();
|
||||
panelApp.use((req, _res, next) => {
|
||||
req.session.userId = 1;
|
||||
req.session.user = { id: 1, username: 'admin', role: 'admin' };
|
||||
next();
|
||||
});
|
||||
const devicesRoutes = require('../routes/devices.routes');
|
||||
panelApp.use('/', rustdeskApiRoutes);
|
||||
panelApp.use('/', devicesRoutes);
|
||||
serverBackend.getAllDevices.mockResolvedValue([
|
||||
{ id: '123456789', hostname: 'PC-1', last_online: '2026-03-26T12:00:00Z' }
|
||||
]);
|
||||
|
||||
const res = await request(panelApp).get('/api/devices');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.devices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('GET /api/devices without Bearer falls through when no panel router is mounted', async () => {
|
||||
const res = await request(app).get('/api/devices');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/devices with Bearer uses rustdesk handler', async () => {
|
||||
db.getAllDevices.mockResolvedValue([{ id: 'abc123', guid: 'g1' }]);
|
||||
const res = await request(app)
|
||||
.get('/api/devices')
|
||||
.set('Authorization', 'Bearer viewer-token');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/strategies without panel handler returns 404 after fallthrough', async () => {
|
||||
const panelApp = createTestApp();
|
||||
panelApp.use((req, _res, next) => {
|
||||
req.session.userId = 1;
|
||||
req.session.user = { id: 1, username: 'admin', role: 'admin' };
|
||||
next();
|
||||
});
|
||||
const devicesRoutes = require('../routes/devices.routes');
|
||||
panelApp.use('/', rustdeskApiRoutes);
|
||||
panelApp.use('/', devicesRoutes);
|
||||
db.getAllStrategies = jest.fn().mockResolvedValue([
|
||||
{ guid: 's1', name: 'Default', enabled: 1, permissions: {} }
|
||||
]);
|
||||
|
||||
const res = await request(panelApp).get('/api/strategies');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user