CVE-2023-51697: Server-Side Request Forgery (SSRF) (CWE-918)
value: Audiobookshelf is a self-hosted audiobook and podcast server. Prior to 2.7.0, Audiobookshelf is vulnerable to unauthenticated blind server-side request (SSRF) vulnerability in `podcastUtils.js`. This vulnerability has been addressed in version 2.7.0. There are no known workarounds for this vulnerability.


CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 7.5
Published: 2023-12-27T18:15Z
Repository: https://github.com/advplyr/audiobookshelf
Language: JavaScript
Files changed: server/controllers/PodcastController.js, server/utils/index.js, server/utils/podcastUtils.js
Commit date: 2023-12-17 12:00:11 -0600
Commit message: Update:API endpoint /podcasts/feed validates rssFeed URL and uses SSRF req filter

Diff: diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index 035f9152..e476efd5 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -4,10 +4,11 @@ const Database = require('../Database')
 
 const fs = require('../libs/fsExtra')
 
 const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
 const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
+const { validateUrl } = require('../utils/index')
 
 const Scanner = require('../scanner/Scanner')
 const CoverManager = require('../managers/CoverManager')
 
 const LibraryItem = require('../objects/LibraryItem')
@@ -100,19 +101,28 @@ class PodcastController {
     if (libraryItem.media.autoDownloadEpisodes) {
       this.cronManager.checkUpdatePodcastCron(libraryItem)
     }
   }
 
+  /**
+   * POST: /api/podcasts/feed
+   * 
+   * @typedef getPodcastFeedReqBody
+   * @property {string} rssFeed
+   * 
+   * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req 
+   * @param {import('express').Response} res 
+   */
   async getPodcastFeed(req, res) {
     if (!req.user.isAdminOrUp) {
       Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`)
       return res.sendStatus(403)
     }
 
-    var url = req.body.rssFeed
+    const url = validateUrl(req.body.rssFeed)
     if (!url) {
-      return res.status(400).send('Bad request')
+      return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL')
     }
 
     const podcast = await getPodcastFeed(url)
     if (!podcast) {
       return res.status(404).send('Podcast RSS feed request failed or invalid response data')
diff --git a/server/utils/index.js b/server/utils/index.js
index 0377b173..29a65885 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -9,28 +9,28 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
   if (!caseSensitive) {
     str1 = str1.toLowerCase()
     str2 = str2.toLowerCase()
   }
   const track = Array(str2.length + 1).fill(null).map(() =>
-    Array(str1.length + 1).fill(null));
+    Array(str1.length + 1).fill(null))
   for (let i = 0; i <= str1.length; i += 1) {
-    track[0][i] = i;
+    track[0][i] = i
   }
   for (let j = 0; j <= str2.length; j += 1) {
-    track[j][0] = j;
+    track[j][0] = j
   }
   for (let j = 1; j <= str2.length; j += 1) {
     for (let i = 1; i <= str1.length; i += 1) {
-      const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
+      const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1
       track[j][i] = Math.min(
         track[j][i - 1] + 1, // deletion
         track[j - 1][i] + 1, // insertion
         track[j - 1][i - 1] + indicator, // substitution
-      );
+      )
     }
   }
-  return track[str2.length][str1.length];
+  return track[str2.length][str1.length]
 }
 module.exports.levenshteinDistance = levenshteinDistance
 
 module.exports.isObject = (val) => {
   return val !== null && typeof val === 'object'
@@ -202,6 +202,22 @@ module.exports.asciiOnlyToLowerCase = (str) => {
  * @returns {string}
  */
 module.exports.escapeRegExp = (str) => {
   if (typeof str !== 'string') return ''
   return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/**
+ * Validate url string with URL class
+ * 
+ * @param {string} rawUrl 
+ * @returns {string} null if invalid
+ */
+module.exports.validateUrl = (rawUrl) => {
+  if (!rawUrl || typeof rawUrl !== 'string') return null
+  try {
+    return new URL(rawUrl).toString()
+  } catch (error) {
+    Logger.error(`Invalid URL "${rawUrl}"`, error)
+    return null
+  }
 }
\ No newline at end of file
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index 87b080d7..819ec914 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -1,7 +1,8 @@
-const Logger = require('../Logger')
 const axios = require('axios')
+const ssrfFilter = require('ssrf-req-filter')
+const Logger = require('../Logger')
 const { xmlToJSON, levenshteinDistance } = require('./index')
 const htmlSanitizer = require('../utils/htmlSanitizer')
 
 function extractFirstArrayItem(json, key) {
   if (!json[key]?.length) return null
@@ -214,13 +215,30 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
       podcast
     }
   }
 }
 
+/**
+ * Get podcast RSS feed as JSON
+ * Uses SSRF filter to prevent internal URLs
+ * 
+ * @param {string} feedUrl 
+ * @param {boolean} [excludeEpisodeMetadata=false]
+ * @returns {Promise}
+ */
 module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
   Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
-  return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml' } }).then(async (data) => {
+
+  return axios({
+    url: feedUrl,
+    method: 'GET',
+    timeout: 12000,
+    responseType: 'arraybuffer',
+    headers: { Accept: 'application/rss+xml' },
+    httpAgent: ssrfFilter(feedUrl),
+    httpsAgent: ssrfFilter(feedUrl)
+  }).then(async (data) => {
 
     // Adding support for ios-8859-1 encoded RSS feeds.
     //  See: https://github.com/advplyr/audiobookshelf/issues/1489
     const contentType = data.headers?.['content-type'] || '' // e.g. text/xml; charset=iso-8859-1
     if (contentType.toLowerCase().includes('iso-8859-1')) {
@@ -229,25 +247,25 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
       data.data = data.data.toString()
     }
 
     if (!data?.data) {
       Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
-      return false
+      return null
     }
     Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`)
     const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
     if (!payload) {
-      return false
+      return null
     }
 
     // RSS feed may be a private RSS feed
     payload.podcast.metadata.feedUrl = feedUrl
 
     return payload.podcast
   }).catch((error) => {
     Logger.error('[podcastUtils] getPodcastFeed Error', error)
-    return false
+    return null
   })
 }
 
 // Return array of episodes ordered by closest match (Levenshtein distance of 6 or less)
 module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {

Vulnerable code:     var url = req.body.rssFeed
      return res.status(400).send('Bad request')
    Array(str1.length + 1).fill(null));
    track[0][i] = i;
    track[j][0] = j;
      const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
      );
  return track[str2.length][str1.length];
const Logger = require('../Logger')
  return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml' } }).then(async (data) => {
      return false
      return false
    return false
Fixed code: const { validateUrl } = require('../utils/index')
  /**
   * POST: /api/podcasts/feed
   * 
   * @typedef getPodcastFeedReqBody
   * @property {string} rssFeed
   * 
   * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req 
   * @param {import('express').Response} res 
   */
    const url = validateUrl(req.body.rssFeed)
      return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL')
    Array(str1.length + 1).fill(null))
    track[0][i] = i
    track[j][0] = j
      const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1
      )
  return track[str2.length][str1.length]
}

/**
 * Validate url string with URL class
 * 
 * @param {string} rawUrl 
 * @returns {string} null if invalid
 */
module.exports.validateUrl = (rawUrl) => {
  if (!rawUrl || typeof rawUrl !== 'string') return null
  try {
    return new URL(rawUrl).toString()
  } catch (error) {
    Logger.error(`Invalid URL "${rawUrl}"`, error)
    return null
  }
const ssrfFilter = require('ssrf-req-filter')
const Logger = require('../Logger')
/**
 * Get podcast RSS feed as JSON
 * Uses SSRF filter to prevent internal URLs
 * 
 * @param {string} feedUrl 
 * @param {boolean} [excludeEpisodeMetadata=false]
 * @returns {Promise}
 */

  return axios({
    url: feedUrl,
    method: 'GET',
    timeout: 12000,
    responseType: 'arraybuffer',
    headers: { Accept: 'application/rss+xml' },
    httpAgent: ssrfFilter(feedUrl),
    httpsAgent: ssrfFilter(feedUrl)
  }).then(async (data) => {
      return null
      return null
    return null