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:
UNITRONIX
2026-07-05 21:16:21 +02:00
parent 5d435eb9ba
commit 2b68744dd5
10 changed files with 176 additions and 24 deletions
+4
View File
@@ -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)_
+36 -3
View File
@@ -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
};
+8
View File
@@ -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);
});
+27 -13
View File
@@ -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;
+2 -2
View File
@@ -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 = [];
+7 -1
View File
@@ -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;
+14 -3
View File
@@ -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);
+3 -1
View File
@@ -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
+19 -1
View File
@@ -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);
});
});
});