From 2985f279c679e59d3d858af4c9578dc9e4f86895 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 d6f748a1..b4f9647c 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 bcfcc7453164c338af1a2478ceeab89fd4b33880 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 b4f9647c..991b1d70 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 2b4dfd419fdcd5e49c0b6fb34a1b16b1bdd87184 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 991b1d70..f3561760 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