From 9d017a2b855a91b54268617649dc9c53d8f36029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtof?= Date: Tue, 2 Dec 2025 17:44:40 +0100 Subject: [PATCH 1/3] Implement experimental DNS pre-resolution Add custom axios interceptor to resolve DNS manually before requests. This avoids problems with axios' built-in DNS resolution in cases of partial resolution failures. --- server/Server.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/server/Server.js b/server/Server.js index d6f748a1e..b4f9647c4 100644 --- a/server/Server.js +++ b/server/Server.js @@ -7,6 +7,7 @@ const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') const cookieParser = require('cookie-parser') const axios = require('axios') +const dns = require('dns').promises const { version } = require('../package.json') @@ -86,6 +87,49 @@ class Server { global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname) } } + + if (process.env.EXP_DNS_RESOLUTION === '1') { + // https://github.com/advplyr/audiobookshelf/pull/3754 + Logger.info(`[Server] Experimental DNS Resolution Enabled`) + + // Resolve DNS using dns package before making request + axios.interceptors.request.use(async (config) => { + try { + const urlObj = new URL(config.url) + const hostname = urlObj.hostname + let resolved = false + + const resolvers = [ + { protocol: 'IPv4', method: dns.resolve4, format: (ip) => ip }, + { protocol: 'IPv6', method: dns.resolve6, format: (ip) => `[${ip}]` } + ] + if (process.env.PREFER_IPV6 === '1') { + resolvers.reverse() + } + + for (const { protocol, method, format } of resolvers) { + const addresses = await method(hostname).catch(() => null) + if (addresses?.length > 0) { + const ip = format(addresses[0]) + urlObj.hostname = ip + config.url = urlObj.toString() + config.headers = { ...config.headers, Host: hostname } + Logger.debug(`[Server] Resolved ${hostname} -> ${addresses[0]} (${protocol})`) + resolved = true + break + } + } + + if (!resolved) { + throw new Error(`Could not resolve hostname ${hostname} to any IP address`) + } + } catch (err) { + Logger.warn(`[Server] DNS pre-resolution error: ${err.message}`) + } + return config + }) + } + global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000) global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24) From 27cba5c88ae26bf6553b2d50fae165b5ec427b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtof?= Date: Tue, 2 Dec 2025 18:38:05 +0100 Subject: [PATCH 2/3] Use experimental DNS resolution on redirects This change disables axios' built-in redirect handling and instead handles redirects manually. This ensures that the experimental DNS resolution works on redirects too, and not just on the initial request. --- server/Server.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/Server.js b/server/Server.js index b4f9647c4..991b1d703 100644 --- a/server/Server.js +++ b/server/Server.js @@ -128,6 +128,23 @@ class Server { } return config }) + + // Manually handle redirects, otherwise axios would bypass custom dns resolution on redirects + axios.defaults.maxRedirects = 0 + axios.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response && [301, 302, 303, 307, 308].includes(error.response.status) && error.response.headers.location) { + const redirectUrl = error.response.headers.location + Logger.debug(`[Server] Following ${error.response.status} redirect to ${redirectUrl}`) + return axios({ + ...error.config, + url: redirectUrl + }) + } + return Promise.reject(error) + } + ) } global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000) From 87f9ecc69899600bd5317c9f227051cf7840c705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtof?= Date: Tue, 2 Dec 2025 21:33:04 +0100 Subject: [PATCH 3/3] Handle redirect loops and maximum redirect limits The implementation of the experimental DNS resolution was vulnerable to infinite redirect loops. This change enforces the maximum number of redirects per web request and detects redirect loops early by tracking visited URLs in a chain of redirects. --- server/Server.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 991b1d703..f35617608 100644 --- a/server/Server.js +++ b/server/Server.js @@ -7,6 +7,7 @@ const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') const cookieParser = require('cookie-parser') const axios = require('axios') +const { AxiosError } = require('axios') const dns = require('dns').promises const { version } = require('../package.json') @@ -130,13 +131,29 @@ class Server { }) // Manually handle redirects, otherwise axios would bypass custom dns resolution on redirects + const maxRedirects = axios.defaults.maxRedirects || 21 // axios default axios.defaults.maxRedirects = 0 axios.interceptors.response.use( (response) => response, async (error) => { if (error.response && [301, 302, 303, 307, 308].includes(error.response.status) && error.response.headers.location) { const redirectUrl = error.response.headers.location - Logger.debug(`[Server] Following ${error.response.status} redirect to ${redirectUrl}`) + if (!error.config._redirectCount) { + error.config._redirectCount = 0 + error.config._redirectUrls = new Set() + } + if (error.config._redirectUrls.has(redirectUrl)) { + const visitedUrls = Array.from(error.config._redirectUrls).join(' -> ') + Logger.error(`[Server] Redirect loop detected: ${visitedUrls} -> ${redirectUrl}`) + return Promise.reject(new AxiosError(`Redirect loop detected: ${redirectUrl}`, 'ERR_FR_TOO_MANY_REDIRECTS', error.config, error.request)) + } + if (error.config._redirectCount >= maxRedirects) { + Logger.error(`[Server] Maximum redirect limit (${maxRedirects}) exceeded`) + return Promise.reject(new AxiosError(`Maximum number of redirects exceeded (${maxRedirects})`, 'ERR_FR_TOO_MANY_REDIRECTS', error.config, error.request)) + } + Logger.debug(`[Server] Following ${error.response.status} redirect to ${redirectUrl} (${error.config._redirectCount + 1}/${maxRedirects})`) + error.config._redirectUrls.add(redirectUrl) + error.config._redirectCount++ return axios({ ...error.config, url: redirectUrl