fix(auth): harden OIDC web callback URL validation

This commit is contained in:
mikiher 2026-07-16 17:11:11 +03:00
parent 527745b54e
commit 0ce37004af
2 changed files with 103 additions and 37 deletions

View file

@ -4,6 +4,7 @@ const OpenIDClient = require('openid-client')
const axios = require('axios') const axios = require('axios')
const Database = require('../Database') const Database = require('../Database')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getRequestOrigin } = require('../utils/requestUtils')
/** /**
* OpenID Connect authentication strategy * OpenID Connect authentication strategy
@ -289,8 +290,8 @@ class OidcAuthStrategy {
const sessionKey = strategy._key const sessionKey = strategy._key
try { try {
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' const { origin } = getRequestOrigin(req)
const hostUrl = new URL(`${protocol}://${req.get('host')}`) const hostUrl = new URL(origin)
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
// Only allow code flow (for mobile clients) // Only allow code flow (for mobile clients)
@ -394,11 +395,10 @@ class OidcAuthStrategy {
let postLogoutRedirectUri = null let postLogoutRedirectUri = null
if (authMethod === 'openid') { if (authMethod === 'openid') {
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' const { origin } = getRequestOrigin(req)
const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation // TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl // 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 // 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 // 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 if (!callbackUrl) return false
try { try {
// Handle relative URLs - these are always safe if they start with router base path // Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
if (callbackUrl.startsWith('/')) { // which browsers resolve to a cross-origin absolute URL.
// Only allow relative paths that start with the router base path if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
if (callbackUrl.startsWith(global.RouterBasePath + '/')) { Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
return true 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}`) Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
return false return false
} }
// For absolute URLs, ensure they point to the same origin return true
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
} catch (error) { } catch (error) {
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error) Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
return false return false

View file

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