diff --git a/server/Server.js b/server/Server.js index ef0888927..bbaaeab43 100644 --- a/server/Server.js +++ b/server/Server.js @@ -87,6 +87,59 @@ class Server { } } global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000) + + // SSRF filter wrapper that also allows NAT64 addresses (RFC 6052) + // The ssrf-req-filter package blocks NAT64 addresses (64:ff9b::/96) because + // ipaddr.js classifies them as "rfc6052" range instead of "unicast". + // We allow NAT64 but still validate the embedded IPv4 is not private. + const ssrfFilterLib = require('ssrf-req-filter') + const net = require('net') + const ipaddr = require('ipaddr.js') + // Check if an IPv4 address string is private/reserved + function isPrivateIPv4(ip) { + try { + const addr = ipaddr.IPv4.parse(ip) + return addr.range() !== 'unicast' + } catch { + return false + } + } + global.getSsrfFilter = (url) => { + const agent = ssrfFilterLib(url) + const originalCreateConnection = agent.createConnection.bind(agent) + agent.createConnection = function(options, callback) { + const { host: address } = options + // Allow NAT64 addresses (64:ff9b::/96 prefix) but validate embedded IPv4 + if (address && address.startsWith('64:ff9b::')) { + try { + const addr = ipaddr.parse(address) + if (addr.kind() === 'ipv6') { + // Extract the embedded IPv4 from the last 32 bits of the NAT64 address + // NAT64 format: 64:ff9b::[96-bit prefix][32-bit IPv4] + // The IPv4 is encoded as two 16-bit hex groups in the last two positions + const parts = addr.toNormalizedString().split(':') + if (parts.length >= 2) { + // Last two 16-bit groups contain the 32-bit IPv4 + const high = parseInt(parts[parts.length - 2], 16) + const low = parseInt(parts[parts.length - 1], 16) + if (!isNaN(high) && !isNaN(low)) { + const ipv4 = `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}` + if (isPrivateIPv4(ipv4)) { + throw new Error(`NAT64 address embeds private IPv4: ${ipv4}`) + } + } + } + } + } catch (e) { + throw new Error(`Call to ${address} is blocked: ${e.message}`) + } + return net.createConnection({ host: options.host, port: options.port }, callback) + } + return originalCreateConnection(options, callback) + } + return agent + } + global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24) if (!fs.pathExistsSync(global.ConfigPath)) { diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 7ad2a3aee..6976134e7 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -1,5 +1,4 @@ const axios = require('axios') -const ssrfFilter = require('ssrf-req-filter') const Ffmpeg = require('../libs/fluentFfmpeg') const ffmpgegUtils = require('../libs/fluentFfmpeg/utils') const fs = require('../libs/fsExtra') @@ -125,8 +124,8 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { 'User-Agent': userAgent }, timeout: global.PodcastDownloadTimeout, - httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url), - httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url) + httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : global.getSsrfFilter(podcastEpisodeDownload.url), + httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : global.getSsrfFilter(podcastEpisodeDownload.url) }) Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 9a349bd54..95c6ddd27 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -1,6 +1,5 @@ const axios = require('axios') const Path = require('path') -const ssrfFilter = require('ssrf-req-filter') const exec = require('child_process').exec const fs = require('../libs/fsExtra') const rra = require('../libs/recursiveReaddirAsync') @@ -306,8 +305,8 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)' }, timeout: 30000, - httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url), - httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url) + httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : global.getSsrfFilter(url), + httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : global.getSsrfFilter(url) }) .then((response) => { // Validate content type diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 1cb0c4cb4..1cff93266 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -1,5 +1,4 @@ const axios = require('axios') -const ssrfFilter = require('ssrf-req-filter') const Logger = require('../Logger') const { xmlToJSON, timestampToSeconds } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') @@ -373,8 +372,8 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { 'Accept-Encoding': 'gzip, compress, deflate', 'User-Agent': userAgent }, - httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl), - httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl) + httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : global.getSsrfFilter(feedUrl), + httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : global.getSsrfFilter(feedUrl) }) .then(async (data) => { // Adding support for ios-8859-1 encoded RSS feeds.