From 2b68744dd50cf43bd71bec97893f8d422387da85 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:16:21 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 ++ web-nodejs/middleware/rateLimiter.js | 39 ++++++++++++-- web-nodejs/public/js/desktop-widgets.js | 8 +++ web-nodejs/public/js/devices.js | 40 +++++++++----- web-nodejs/public/js/users.js | 4 +- web-nodejs/public/js/utils.js | 8 ++- web-nodejs/routes/rustdesk-api.routes.js | 17 ++++-- web-nodejs/server.js | 4 +- web-nodejs/tests/rateLimiter.test.js | 20 ++++++- web-nodejs/tests/rustdesk-api.routes.test.js | 56 ++++++++++++++++++++ 10 files changed, 176 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0a6820..a75bbe63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)_ diff --git a/web-nodejs/middleware/rateLimiter.js b/web-nodejs/middleware/rateLimiter.js index 53fda5b7..4c1d18e0 100644 --- a/web-nodejs/middleware/rateLimiter.js +++ b/web-nodejs/middleware/rateLimiter.js @@ -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 }; diff --git a/web-nodejs/public/js/desktop-widgets.js b/web-nodejs/public/js/desktop-widgets.js index 68d1b2cc..816287f5 100644 --- a/web-nodejs/public/js/desktop-widgets.js +++ b/web-nodejs/public/js/desktop-widgets.js @@ -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); }); diff --git a/web-nodejs/public/js/devices.js b/web-nodejs/public/js/devices.js index 1d159a49..f8ae59ef 100644 --- a/web-nodejs/public/js/devices.js +++ b/web-nodejs/public/js/devices.js @@ -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; diff --git a/web-nodejs/public/js/users.js b/web-nodejs/public/js/users.js index 8223ea8c..fd047f7d 100644 --- a/web-nodejs/public/js/users.js +++ b/web-nodejs/public/js/users.js @@ -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 = []; diff --git a/web-nodejs/public/js/utils.js b/web-nodejs/public/js/utils.js index 27f33216..b2ce6124 100644 --- a/web-nodejs/public/js/utils.js +++ b/web-nodejs/public/js/utils.js @@ -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; diff --git a/web-nodejs/routes/rustdesk-api.routes.js b/web-nodejs/routes/rustdesk-api.routes.js index ec48bec8..afe23070 100644 --- a/web-nodejs/routes/rustdesk-api.routes.js +++ b/web-nodejs/routes/rustdesk-api.routes.js @@ -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); diff --git a/web-nodejs/server.js b/web-nodejs/server.js index 39a56781..84702175 100644 --- a/web-nodejs/server.js +++ b/web-nodejs/server.js @@ -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 diff --git a/web-nodejs/tests/rateLimiter.test.js b/web-nodejs/tests/rateLimiter.test.js index 2928963d..47ac6615 100644 --- a/web-nodejs/tests/rateLimiter.test.js +++ b/web-nodejs/tests/rateLimiter.test.js @@ -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); + }); }); diff --git a/web-nodejs/tests/rustdesk-api.routes.test.js b/web-nodejs/tests/rustdesk-api.routes.test.js index 02de59ef..b47db2b1 100644 --- a/web-nodejs/tests/rustdesk-api.routes.test.js +++ b/web-nodejs/tests/rustdesk-api.routes.test.js @@ -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); + }); + }); }); \ No newline at end of file