From 527745b54ea474189d45bc1156a6a1aea1796092 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 16 Jul 2026 17:10:42 +0300 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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', () => {