Compare commits

..

9 commits

Author SHA1 Message Date
advplyr
2d9f63963e
Merge pull request #5370 from mikiher/fix/root-user-delete-protection
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
Prevent admin users from deleting the root account
2026-07-20 18:35:32 -04:00
advplyr
f12abca3fc Simplify user delete checks 2026-07-20 17:33:15 -05:00
advplyr
bced308462 Trim down UserController test 2026-07-20 17:31:03 -05:00
advplyr
528d03fef3
Merge pull request #5386 from mikiher/fix/oidc-url-validation
Fix oidc url validation
2026-07-20 18:08:51 -04:00
advplyr
b93c3133a9 Update x-forwarded-proto to check comma separated value 2026-07-20 17:06:21 -05:00
mikiher
8d9db8d103 fix OidcAuthStrategy test module load order 2026-07-20 18:43:15 +03:00
mikiher
0ce37004af fix(auth): harden OIDC web callback URL validation 2026-07-16 17:11:11 +03:00
mikiher
527745b54e refactor(server): centralize request HTTPS and origin detection 2026-07-16 17:10:42 +03:00
mikiher
3c016314e6 fix(auth): prevent admin users from deleting the root account 2026-07-15 20:34:02 +03:00
9 changed files with 233 additions and 47 deletions

View file

@ -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}`
}

View file

@ -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

View file

@ -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: '/'

View file

@ -363,15 +363,16 @@ class UserController {
* @param {Response} res
*/
async delete(req, res) {
if (req.params.id === 'root') {
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
return res.sendStatus(400)
}
const user = req.reqUser
if (req.user.id === req.params.id) {
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
return res.sendStatus(400)
}
const user = req.reqUser
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

View file

@ -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')
}
}
}
}
)
}

View file

@ -0,0 +1,43 @@
/**
* Whether the request was made over HTTPS.
* Uses Express `req.secure` and `x-forwarded-proto`
*
* @param {import('express').Request} req
* @returns {boolean}
*/
function isRequestSecure(req) {
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')
)
}
/**
* @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
}

View file

@ -0,0 +1,77 @@
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')
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)
})
})

View file

@ -0,0 +1,26 @@
const { expect } = require('chai')
const sinon = require('sinon')
const UserController = require('../../../server/controllers/UserController')
const Logger = require('../../../server/Logger')
describe('UserController - delete', () => {
beforeEach(() => {
sinon.stub(Logger, 'error')
})
afterEach(() => {
sinon.restore()
})
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({ user: adminUser, reqUser: rootUser, params: { id: rootUser.id } }, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
expect(fakeRes.json.called).to.be.false
})
})

View file

@ -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 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(true)
})
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'
})
})
})