From 3c016314e6e4752b825d9c3e3fb04d4148ce3c71 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Jul 2026 20:34:02 +0300 Subject: [PATCH 1/7] fix(auth): prevent admin users from deleting the root account --- server/controllers/UserController.js | 9 +- server/models/User.js | 9 +- .../server/controllers/UserController.test.js | 177 ++++++++++++++++++ 3 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 test/server/controllers/UserController.test.js diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 3ec10539e..c0cbfc747 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -363,7 +363,13 @@ class UserController { * @param {Response} res */ async delete(req, res) { - if (req.params.id === 'root') { + const user = req.reqUser + + if (user.isRoot && !req.user.isRoot) { + Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`) + return res.sendStatus(403) + } + if (user.isRoot) { Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted') return res.sendStatus(400) } @@ -371,7 +377,6 @@ class UserController { Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`) return res.sendStatus(400) } - const user = req.reqUser // Todo: check if user is logged in and cancel streams diff --git a/server/models/User.js b/server/models/User.js index f380f8e4f..0b9d49438 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -530,7 +530,14 @@ class User extends Model { }, { sequelize, - modelName: 'user' + modelName: 'user', + hooks: { + beforeDestroy(user) { + if (user.type === 'root') { + throw new Error('Root user cannot be deleted') + } + } + } } ) } diff --git a/test/server/controllers/UserController.test.js b/test/server/controllers/UserController.test.js new file mode 100644 index 000000000..629788d84 --- /dev/null +++ b/test/server/controllers/UserController.test.js @@ -0,0 +1,177 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const UserController = require('../../../server/controllers/UserController') +const Logger = require('../../../server/Logger') +const SocketAuthority = require('../../../server/SocketAuthority') + +function createFakeRes() { + return { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy(), + json: sinon.spy() + } +} + +describe('UserController - delete root protection', () => { + let rootUser + let adminUser + let regularUser + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + sinon.stub(SocketAuthority, 'adminEmitter') + + rootUser = await Database.userModel.create({ + username: 'root', + pash: 'hashed_password_root', + type: 'root', + isActive: true + }) + + adminUser = await Database.userModel.create({ + username: 'admin', + pash: 'hashed_password_admin', + type: 'admin', + isActive: true + }) + + regularUser = await Database.userModel.create({ + username: 'regular', + pash: 'hashed_password_regular', + type: 'user', + isActive: true + }) + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + it('should prevent admin from deleting root user by UUID (403)', async () => { + const fakeReq = { + user: adminUser, + reqUser: rootUser, + params: { id: rootUser.id } + } + const fakeRes = createFakeRes() + + await UserController.delete(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + expect(fakeRes.json.called).to.be.false + + const existingRoot = await Database.userModel.findByPk(rootUser.id) + expect(existingRoot).to.not.be.null + expect(existingRoot.type).to.equal('root') + }) + + it('should prevent root from deleting root user (400)', async () => { + const fakeReq = { + user: rootUser, + reqUser: rootUser, + params: { id: rootUser.id } + } + const fakeRes = createFakeRes() + + await UserController.delete(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(400)).to.be.true + expect(fakeRes.json.called).to.be.false + + const existingRoot = await Database.userModel.findByPk(rootUser.id) + expect(existingRoot).to.not.be.null + }) + + it('should not block deletion when URL param is literal "root" but target is a different user', async () => { + const fakeReq = { + user: adminUser, + reqUser: regularUser, + params: { id: 'root' } + } + const fakeRes = createFakeRes() + + await UserController.delete(fakeReq, fakeRes) + + expect(fakeRes.json.calledWith({ success: true })).to.be.true + + const deletedUser = await Database.userModel.findByPk(regularUser.id) + expect(deletedUser).to.be.null + }) + + it('should allow admin to delete a regular user (200)', async () => { + const fakeReq = { + user: adminUser, + reqUser: regularUser, + params: { id: regularUser.id } + } + const fakeRes = createFakeRes() + + await UserController.delete(fakeReq, fakeRes) + + expect(fakeRes.json.calledWith({ success: true })).to.be.true + expect(SocketAuthority.adminEmitter.calledWith('user_removed')).to.be.true + + const deletedUser = await Database.userModel.findByPk(regularUser.id) + expect(deletedUser).to.be.null + }) + + it('should prevent admin from deleting self (400)', async () => { + const fakeReq = { + user: adminUser, + reqUser: adminUser, + params: { id: adminUser.id } + } + const fakeRes = createFakeRes() + + await UserController.delete(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(400)).to.be.true + expect(fakeRes.json.called).to.be.false + + const existingAdmin = await Database.userModel.findByPk(adminUser.id) + expect(existingAdmin).to.not.be.null + }) +}) + +describe('User model - beforeDestroy root protection', () => { + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + }) + + afterEach(async () => { + await Database.sequelize.sync({ force: true }) + }) + + it('should reject direct destroy of root user', async () => { + const rootUser = await Database.userModel.create({ + username: 'root', + pash: 'hashed_password_root', + type: 'root', + isActive: true + }) + + try { + await rootUser.destroy() + expect.fail('Expected destroy to throw') + } catch (error) { + expect(error.message).to.equal('Root user cannot be deleted') + } + + const existingRoot = await Database.userModel.findByPk(rootUser.id) + expect(existingRoot).to.not.be.null + }) +}) From 527745b54ea474189d45bc1156a6a1aea1796092 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 16 Jul 2026 17:10:42 +0300 Subject: [PATCH 2/7] refactor(server): centralize request HTTPS and origin detection --- server/Server.js | 6 ++-- server/auth/TokenManager.js | 3 +- server/utils/requestUtils.js | 34 ++++++++++++++++++++++ test/server/utils/requestUtils.test.js | 40 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 server/utils/requestUtils.js create mode 100644 test/server/utils/requestUtils.test.js diff --git a/server/Server.js b/server/Server.js index ef0888927..c1657ff1e 100644 --- a/server/Server.js +++ b/server/Server.js @@ -14,6 +14,7 @@ const { version } = require('../package.json') const is = require('./libs/requestIp/isJs') const fileUtils = require('./utils/fileUtils') const { toNumber } = require('./utils/index') +const { getRequestOrigin } = require('./utils/requestUtils') const Logger = require('./Logger') const Auth = require('./Auth') @@ -288,10 +289,9 @@ class Server { // if RouterBasePath is set, modify all requests to include the base path app.use((req, res, next) => { const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) - const host = req.get('host') - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const { origin } = getRequestOrigin(req) const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : '' - req.originalHostPrefix = `${protocol}://${host}${prefix}` + req.originalHostPrefix = `${origin}${prefix}` if (!urlStartsWithRouterBasePath) { req.url = `${global.RouterBasePath}${req.url}` } diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5933209c7..0c59ae7a1 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -6,6 +6,7 @@ const Logger = require('../Logger') const requestIp = require('../libs/requestIp') const jwt = require('../libs/jsonwebtoken') +const { isRequestSecure } = require('../utils/requestUtils') class TokenManager { /** @type {string} JWT secret key */ @@ -59,7 +60,7 @@ class TokenManager { setRefreshTokenCookie(req, res, refreshToken) { res.cookie('refresh_token', refreshToken, { httpOnly: true, - secure: req.secure || req.get('x-forwarded-proto') === 'https', + secure: isRequestSecure(req), sameSite: 'lax', maxAge: this.RefreshTokenExpiry * 1000, path: '/' diff --git a/server/utils/requestUtils.js b/server/utils/requestUtils.js new file mode 100644 index 000000000..f2672d977 --- /dev/null +++ b/server/utils/requestUtils.js @@ -0,0 +1,34 @@ +/** + * Whether the request was made over HTTPS. + * Uses Express `req.secure` and a strict `x-forwarded-proto === 'https'` check. + * + * @param {import('express').Request} req + * @returns {boolean} + */ +function isRequestSecure(req) { + return req.secure || req.get('x-forwarded-proto') === 'https' +} + +/** + * @param {import('express').Request} req + * @returns {'https' | 'http'} + */ +function getRequestProtocol(req) { + return isRequestSecure(req) ? 'https' : 'http' +} + +/** + * @param {import('express').Request} req + * @returns {{ protocol: 'https' | 'http', host: string, origin: string }} + */ +function getRequestOrigin(req) { + const protocol = getRequestProtocol(req) + const host = req.get('host') + return { protocol, host, origin: `${protocol}://${host}` } +} + +module.exports = { + isRequestSecure, + getRequestProtocol, + getRequestOrigin +} diff --git a/test/server/utils/requestUtils.test.js b/test/server/utils/requestUtils.test.js new file mode 100644 index 000000000..ee01af5a0 --- /dev/null +++ b/test/server/utils/requestUtils.test.js @@ -0,0 +1,40 @@ +const { expect } = require('chai') + +const { isRequestSecure, getRequestProtocol, getRequestOrigin } = require('../../../server/utils/requestUtils') + +function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) { + return { + secure, + get(header) { + if (header === 'host') return host + if (header === 'x-forwarded-proto') return xForwardedProto + return null + } + } +} + +describe('requestUtils', () => { + it('isRequestSecure uses req.secure', () => { + expect(isRequestSecure(mockReq({ secure: true }))).to.equal(true) + expect(isRequestSecure(mockReq({ secure: false }))).to.equal(false) + }) + + it('isRequestSecure uses strict x-forwarded-proto check', () => { + expect(isRequestSecure(mockReq({ xForwardedProto: 'https' }))).to.equal(true) + expect(isRequestSecure(mockReq({ xForwardedProto: 'http' }))).to.equal(false) + expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https' }))).to.equal(false) + }) + + it('getRequestProtocol returns https or http', () => { + expect(getRequestProtocol(mockReq({ secure: true }))).to.equal('https') + expect(getRequestProtocol(mockReq())).to.equal('http') + }) + + it('getRequestOrigin builds origin from protocol and host', () => { + expect(getRequestOrigin(mockReq({ secure: true }))).to.deep.equal({ + protocol: 'https', + host: 'books.example.com', + origin: 'https://books.example.com' + }) + }) +}) From 0ce37004afc60fb9fe58d929bb2e827a1e36b55a Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 16 Jul 2026 17:11:11 +0300 Subject: [PATCH 3/7] fix(auth): harden OIDC web callback URL validation --- server/auth/OidcAuthStrategy.js | 65 +++++++++----------- test/server/auth/OidcAuthStrategy.test.js | 75 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 test/server/auth/OidcAuthStrategy.test.js diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js index 64ab82448..0997b7cf3 100644 --- a/server/auth/OidcAuthStrategy.js +++ b/server/auth/OidcAuthStrategy.js @@ -4,6 +4,7 @@ const OpenIDClient = require('openid-client') const axios = require('axios') const Database = require('../Database') const Logger = require('../Logger') +const { getRequestOrigin } = require('../utils/requestUtils') /** * OpenID Connect authentication strategy @@ -289,8 +290,8 @@ class OidcAuthStrategy { const sessionKey = strategy._key try { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const hostUrl = new URL(`${protocol}://${req.get('host')}`) + const { origin } = getRequestOrigin(req) + const hostUrl = new URL(origin) const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge // Only allow code flow (for mobile clients) @@ -394,11 +395,10 @@ class OidcAuthStrategy { let postLogoutRedirectUri = null if (authMethod === 'openid') { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const host = req.get('host') + const { origin } = getRequestOrigin(req) // TODO: ABS does currently not support subfolders for installation // If we want to support it we need to include a config for the serverurl - postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` + postLogoutRedirectUri = `${origin}${global.RouterBasePath}/login` } // else for openid-mobile we keep postLogoutRedirectUri on null // nice would be to redirect to the app here, but for example Authentik does not implement @@ -515,42 +515,33 @@ class OidcAuthStrategy { if (!callbackUrl) return false try { - // Handle relative URLs - these are always safe if they start with router base path - if (callbackUrl.startsWith('/')) { - // Only allow relative paths that start with the router base path - if (callbackUrl.startsWith(global.RouterBasePath + '/')) { - return true - } + // Reject protocol-relative (//host) and backslash-prefixed (/\host) values, + // which browsers resolve to a cross-origin absolute URL. + if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) { + Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`) + return false + } + + const { origin: serverOrigin } = getRequestOrigin(req) + const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl) + + if (resolvedUrl.origin !== serverOrigin) { + Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`) + return false + } + + const pathname = decodeURIComponent(resolvedUrl.pathname) + if (pathname.startsWith('//') || pathname.startsWith('/\\')) { + Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`) + return false + } + + if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) { Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`) return false } - // For absolute URLs, ensure they point to the same origin - const callbackUrlObj = new URL(callbackUrl) - // NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both - const xfp = (req.get('x-forwarded-proto') || '').toLowerCase() - const currentProtocol = - req.secure || - xfp - .split(',') - .map((s) => s.trim()) - .includes('https') - ? 'https' - : 'http' - const currentHost = req.get('host') - - // Check if protocol and host match exactly - if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) { - // Additional check: ensure path starts with router base path - if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) { - return true - } - Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`) - return false - } - - Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`) - return false + return true } catch (error) { Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error) return false diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js new file mode 100644 index 000000000..00f950853 --- /dev/null +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -0,0 +1,75 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy') +const Logger = require('../../../server/Logger') + +describe('OidcAuthStrategy - isValidWebCallbackUrl', () => { + /** @type {OidcAuthStrategy} */ + let strategy + + beforeEach(() => { + global.RouterBasePath = '' + strategy = new OidcAuthStrategy() + sinon.stub(Logger, 'warn') + sinon.stub(Logger, 'error') + }) + + afterEach(() => { + sinon.restore() + }) + + function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) { + return { + secure, + get(header) { + if (header === 'host') return host + if (header === 'x-forwarded-proto') return xForwardedProto + return null + } + } + } + + it('accepts a same-origin relative path when router base path is empty', () => { + expect(strategy.isValidWebCallbackUrl('/library', mockReq())).to.equal(true) + }) + + it('accepts a same-origin absolute https URL', () => { + const req = mockReq({ secure: true }) + expect(strategy.isValidWebCallbackUrl('https://books.example.com/library', req)).to.equal(true) + }) + + it('rejects protocol-relative URLs', () => { + expect(strategy.isValidWebCallbackUrl('//evil.example/capture', mockReq())).to.equal(false) + }) + + it('rejects backslash-prefixed URLs', () => { + expect(strategy.isValidWebCallbackUrl('/\\evil.example/capture', mockReq())).to.equal(false) + }) + + it('rejects absolute external URLs', () => { + expect(strategy.isValidWebCallbackUrl('http://evil.example/capture', mockReq())).to.equal(false) + }) + + it('rejects encoded protocol-relative path segments', () => { + expect(strategy.isValidWebCallbackUrl('/%2F%2Fevil.example/capture', mockReq())).to.equal(false) + }) + + it('rejects same-origin URLs outside router base path', () => { + global.RouterBasePath = '/audiobookshelf' + expect(strategy.isValidWebCallbackUrl('/login', mockReq())).to.equal(false) + expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', mockReq())).to.equal(true) + }) + + it('rejects empty and malformed callback URLs', () => { + expect(strategy.isValidWebCallbackUrl('', mockReq())).to.equal(false) + expect(strategy.isValidWebCallbackUrl(null, mockReq())).to.equal(false) + expect(strategy.isValidWebCallbackUrl('not a url', mockReq())).to.equal(false) + }) + + it('uses x-forwarded-proto when determining same-origin https URLs', () => { + const req = mockReq({ xForwardedProto: 'https' }) + expect(strategy.isValidWebCallbackUrl('https://books.example.com/login', req)).to.equal(true) + expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false) + }) +}) From 8d9db8d1031395061c40c755eb0b11dc906c931c Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 20 Jul 2026 18:43:15 +0300 Subject: [PATCH 4/7] fix OidcAuthStrategy test module load order --- test/server/auth/OidcAuthStrategy.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js index 00f950853..6a84edac0 100644 --- a/test/server/auth/OidcAuthStrategy.test.js +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -1,6 +1,8 @@ const { expect } = require('chai') const sinon = require('sinon') +// Load Database first so Auth resolves OidcAuthStrategy before the circular require completes. +require('../../../server/Database') const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy') const Logger = require('../../../server/Logger') From b93c3133a9e912cd32e145bbacf8a40213e93dc1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jul 2026 17:06:21 -0500 Subject: [PATCH 5/7] Update x-forwarded-proto to check comma separated value --- server/utils/requestUtils.js | 13 +++++++++++-- test/server/utils/requestUtils.test.js | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/server/utils/requestUtils.js b/server/utils/requestUtils.js index f2672d977..afac4fcc0 100644 --- a/server/utils/requestUtils.js +++ b/server/utils/requestUtils.js @@ -1,12 +1,21 @@ /** * Whether the request was made over HTTPS. - * Uses Express `req.secure` and a strict `x-forwarded-proto === 'https'` check. + * Uses Express `req.secure` and `x-forwarded-proto` * * @param {import('express').Request} req * @returns {boolean} */ function isRequestSecure(req) { - return req.secure || req.get('x-forwarded-proto') === 'https' + if (req.secure) return true + const xfp = (req.get('x-forwarded-proto') || '').toLowerCase() + // Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635 + return ( + xfp === 'https' || + xfp + .split(',') + .map((s) => s.trim()) + .includes('https') + ) } /** diff --git a/test/server/utils/requestUtils.test.js b/test/server/utils/requestUtils.test.js index ee01af5a0..589c6f1cd 100644 --- a/test/server/utils/requestUtils.test.js +++ b/test/server/utils/requestUtils.test.js @@ -19,10 +19,10 @@ describe('requestUtils', () => { expect(isRequestSecure(mockReq({ secure: false }))).to.equal(false) }) - it('isRequestSecure uses strict x-forwarded-proto check', () => { + it('isRequestSecure uses x-forwarded-proto', () => { expect(isRequestSecure(mockReq({ xForwardedProto: 'https' }))).to.equal(true) expect(isRequestSecure(mockReq({ xForwardedProto: 'http' }))).to.equal(false) - expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https' }))).to.equal(false) + expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https' }))).to.equal(true) }) it('getRequestProtocol returns https or http', () => { From bced3084626c7f2a16900e32a260e957c017c25e Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jul 2026 17:31:03 -0500 Subject: [PATCH 6/7] Trim down UserController test --- .../server/controllers/UserController.test.js | 167 +----------------- 1 file changed, 8 insertions(+), 159 deletions(-) diff --git a/test/server/controllers/UserController.test.js b/test/server/controllers/UserController.test.js index 629788d84..add025d6e 100644 --- a/test/server/controllers/UserController.test.js +++ b/test/server/controllers/UserController.test.js @@ -1,177 +1,26 @@ const { expect } = require('chai') -const { Sequelize } = require('sequelize') const sinon = require('sinon') -const Database = require('../../../server/Database') const UserController = require('../../../server/controllers/UserController') const Logger = require('../../../server/Logger') -const SocketAuthority = require('../../../server/SocketAuthority') -function createFakeRes() { - return { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } -} - -describe('UserController - delete root protection', () => { - let rootUser - let adminUser - let regularUser - - beforeEach(async () => { - global.ServerSettings = {} - Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') - await Database.buildModels() - - sinon.stub(Logger, 'info') +describe('UserController - delete', () => { + beforeEach(() => { sinon.stub(Logger, 'error') - sinon.stub(SocketAuthority, 'adminEmitter') - - rootUser = await Database.userModel.create({ - username: 'root', - pash: 'hashed_password_root', - type: 'root', - isActive: true - }) - - adminUser = await Database.userModel.create({ - username: 'admin', - pash: 'hashed_password_admin', - type: 'admin', - isActive: true - }) - - regularUser = await Database.userModel.create({ - username: 'regular', - pash: 'hashed_password_regular', - type: 'user', - isActive: true - }) }) - afterEach(async () => { + afterEach(() => { sinon.restore() - await Database.sequelize.sync({ force: true }) }) - it('should prevent admin from deleting root user by UUID (403)', async () => { - const fakeReq = { - user: adminUser, - reqUser: rootUser, - params: { id: rootUser.id } - } - const fakeRes = createFakeRes() + it('rejects deleting the root user by UUID', async () => { + const rootUser = { id: 'root-uuid', isRoot: true, username: 'root' } + const adminUser = { id: 'admin-uuid', isRoot: false, username: 'admin' } + const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() } - await UserController.delete(fakeReq, fakeRes) + await UserController.delete({ user: adminUser, reqUser: rootUser, params: { id: rootUser.id } }, fakeRes) expect(fakeRes.sendStatus.calledWith(403)).to.be.true expect(fakeRes.json.called).to.be.false - - const existingRoot = await Database.userModel.findByPk(rootUser.id) - expect(existingRoot).to.not.be.null - expect(existingRoot.type).to.equal('root') - }) - - it('should prevent root from deleting root user (400)', async () => { - const fakeReq = { - user: rootUser, - reqUser: rootUser, - params: { id: rootUser.id } - } - const fakeRes = createFakeRes() - - await UserController.delete(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(400)).to.be.true - expect(fakeRes.json.called).to.be.false - - const existingRoot = await Database.userModel.findByPk(rootUser.id) - expect(existingRoot).to.not.be.null - }) - - it('should not block deletion when URL param is literal "root" but target is a different user', async () => { - const fakeReq = { - user: adminUser, - reqUser: regularUser, - params: { id: 'root' } - } - const fakeRes = createFakeRes() - - await UserController.delete(fakeReq, fakeRes) - - expect(fakeRes.json.calledWith({ success: true })).to.be.true - - const deletedUser = await Database.userModel.findByPk(regularUser.id) - expect(deletedUser).to.be.null - }) - - it('should allow admin to delete a regular user (200)', async () => { - const fakeReq = { - user: adminUser, - reqUser: regularUser, - params: { id: regularUser.id } - } - const fakeRes = createFakeRes() - - await UserController.delete(fakeReq, fakeRes) - - expect(fakeRes.json.calledWith({ success: true })).to.be.true - expect(SocketAuthority.adminEmitter.calledWith('user_removed')).to.be.true - - const deletedUser = await Database.userModel.findByPk(regularUser.id) - expect(deletedUser).to.be.null - }) - - it('should prevent admin from deleting self (400)', async () => { - const fakeReq = { - user: adminUser, - reqUser: adminUser, - params: { id: adminUser.id } - } - const fakeRes = createFakeRes() - - await UserController.delete(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(400)).to.be.true - expect(fakeRes.json.called).to.be.false - - const existingAdmin = await Database.userModel.findByPk(adminUser.id) - expect(existingAdmin).to.not.be.null - }) -}) - -describe('User model - beforeDestroy root protection', () => { - beforeEach(async () => { - global.ServerSettings = {} - Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') - await Database.buildModels() - }) - - afterEach(async () => { - await Database.sequelize.sync({ force: true }) - }) - - it('should reject direct destroy of root user', async () => { - const rootUser = await Database.userModel.create({ - username: 'root', - pash: 'hashed_password_root', - type: 'root', - isActive: true - }) - - try { - await rootUser.destroy() - expect.fail('Expected destroy to throw') - } catch (error) { - expect(error.message).to.equal('Root user cannot be deleted') - } - - const existingRoot = await Database.userModel.findByPk(rootUser.id) - expect(existingRoot).to.not.be.null }) }) From f12abca3fce2ba4ffff47978d915fe86819bde6d Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jul 2026 17:33:15 -0500 Subject: [PATCH 7/7] Simplify user delete checks --- server/controllers/UserController.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index c0cbfc747..4a4da0366 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -365,18 +365,14 @@ class UserController { async delete(req, res) { const user = req.reqUser - if (user.isRoot && !req.user.isRoot) { - Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`) - return res.sendStatus(403) - } - if (user.isRoot) { - Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted') - return res.sendStatus(400) - } if (req.user.id === req.params.id) { Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`) return res.sendStatus(400) } + if (user.isRoot) { + Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`) + return res.sendStatus(403) + } // Todo: check if user is logged in and cancel streams